diff --git a/dd-smoke-tests/spring-boot-rabbit/build.gradle b/dd-smoke-tests/spring-boot-rabbit/build.gradle index e7b91d57bfb..d7fd58b3e6a 100644 --- a/dd-smoke-tests/spring-boot-rabbit/build.gradle +++ b/dd-smoke-tests/spring-boot-rabbit/build.gradle @@ -26,6 +26,7 @@ dependencies { testImplementation project(':dd-smoke-tests') testImplementation group: 'org.testcontainers', name: 'rabbitmq', version: libs.versions.testcontainers.get() + testImplementation group: 'org.testcontainers', name: 'junit-jupiter', version: libs.versions.testcontainers.get() } tasks.withType(Test).configureEach { diff --git a/dd-smoke-tests/spring-boot-rabbit/gradle.lockfile b/dd-smoke-tests/spring-boot-rabbit/gradle.lockfile index f7fe086e170..a19d90f6502 100644 --- a/dd-smoke-tests/spring-boot-rabbit/gradle.lockfile +++ b/dd-smoke-tests/spring-boot-rabbit/gradle.lockfile @@ -80,7 +80,7 @@ org.apache.logging.log4j:log4j-to-slf4j:2.14.1=compileClasspath,runtimeClasspath org.apache.tomcat.embed:tomcat-embed-core:9.0.52=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-el:9.0.52=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-websocket:9.0.52=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath +org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath org.codehaus.groovy:groovy-ant:3.0.23=codenarc org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc @@ -158,6 +158,7 @@ org.springframework:spring-web:5.3.9=compileClasspath,runtimeClasspath,testCompi org.springframework:spring-webmvc:5.3.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.tabletest:tabletest-junit:1.2.1=testCompileClasspath,testRuntimeClasspath org.tabletest:tabletest-parser:1.2.0=testCompileClasspath,testRuntimeClasspath +org.testcontainers:junit-jupiter:1.21.4=testCompileClasspath,testRuntimeClasspath org.testcontainers:rabbitmq:1.21.4=testCompileClasspath,testRuntimeClasspath org.testcontainers:testcontainers:1.21.4=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=spotbugs diff --git a/dd-smoke-tests/spring-boot-rabbit/src/test/groovy/datadog/smoketest/SpringBootRabbitIntegrationTest.groovy b/dd-smoke-tests/spring-boot-rabbit/src/test/groovy/datadog/smoketest/SpringBootRabbitIntegrationTest.groovy deleted file mode 100644 index 797e85a67ab..00000000000 --- a/dd-smoke-tests/spring-boot-rabbit/src/test/groovy/datadog/smoketest/SpringBootRabbitIntegrationTest.groovy +++ /dev/null @@ -1,119 +0,0 @@ -package datadog.smoketest - -import datadog.trace.agent.test.utils.PortUtils -import okhttp3.Request -import org.testcontainers.containers.RabbitMQContainer -import spock.lang.Shared - -import java.util.concurrent.TimeUnit - -class SpringBootRabbitIntegrationTest extends AbstractServerSmokeTest { - - @Shared - def rabbitMQContainer - - @Shared - String rabbitHost - - @Shared - Integer rabbitPort - - def cleanupSpec() { - if (rabbitMQContainer) { - rabbitMQContainer.stop() - } - } - - protected int numberOfProcesses() { - return 2 - } - - @Override - void beforeProcessBuilders() { - rabbitMQContainer = new RabbitMQContainer("rabbitmq:3.9.20-alpine") - rabbitMQContainer.start() - rabbitHost = rabbitMQContainer.getHost() - rabbitPort = rabbitMQContainer.getMappedPort(5672) - PortUtils.waitForPortToOpen(rabbitHost, rabbitPort, 5, TimeUnit.SECONDS) - } - - @Override - ProcessBuilder createProcessBuilder(int processIndex) { - String springBootShadowJar = System.getProperty("datadog.smoketest.springboot.shadowJar.path") - - List command = new ArrayList<>() - command.add(javaPath()) - command.addAll(defaultJavaProperties) - command.addAll((String[]) [ - "-Ddd.service.name=spring-rabbit-${processIndex}", - "-Ddd.rabbit.legacy.tracing.enabled=false", - "-Ddd.writer.type=TraceStructureWriter:${outputs[processIndex].getAbsolutePath()}:includeService:includeResource", - "-jar", - springBootShadowJar, - "--server.port=${httpPorts[processIndex]}", - "--rabbit.${processIndex == 0 ? "sender" : "receiver"}.queue=otherqueue", - ]) - if (processIndex > 0) { - command.add("--rabbit.receiver.forward=true") - } - if (rabbitHost) { - command.add("--spring.rabbitmq.host=$rabbitHost".toString()) - } - if (rabbitPort) { - command.add("--spring.rabbitmq.port=$rabbitPort".toString()) - } - ProcessBuilder processBuilder = new ProcessBuilder(command) - processBuilder.directory(new File(buildDirectory)) - } - - @Override - File createTemporaryFile(int processIndex) { - return new File("${buildDirectory}/tmp/trace-structure-rabbit.${processIndex}.out") - } - - @Override - protected Set expectedTraces(int processIndex) { - def service = "spring-rabbit-${processIndex}" - Set expected = [ - "[${service}:amqp.command:basic.qos]", - "[${service}:amqp.command:basic.consume]", - "[${service}:amqp.command:basic.ack]", - "[${service}:amqp.command:queue.declare]" - ] - if (processIndex == 0) { - expected.add("[${service}:servlet.request:GET /roundtrip/{message}[${service}:spring.handler:WebController.roundtrip[${service}:amqp.command:basic.publish -> otherqueue]]]") - expected.add("[rabbitmq:amqp.deliver:amqp.deliver queue[${service}:amqp.command:basic.deliver queue[${service}:amqp.consume:amqp.consume queue[${service}:spring.consume:Receiver.receiveMessage]]]]") - } else { - expected.add("[rabbitmq:amqp.deliver:amqp.deliver otherqueue[${service}:amqp.command:basic.deliver otherqueue[${service}:amqp.consume:amqp.consume otherqueue[${service}:spring.consume:Receiver.receiveMessage[${service}:amqp.command:basic.publish -> queue]]]]]") - } - return expected - } - - @Override - boolean isErrorLog(String log) { - if (log.contains('org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer - Failed to check/redeclare auto-delete queue(s).')) { - return false - } - if (log.contains('ERROR com.rabbitmq.client.impl.ForgivingExceptionHandler - An unexpected connection driver error occured')) { - return false - } - return super.isErrorLog(log) - } - - def "check message #message roundtrip"() { - setup: - String url = "http://localhost:${httpPort}/roundtrip/${message}" - - when: - def request = new Request.Builder().url(url).get().build() - def response = client.newCall(request).execute() - - then: - def responseBodyStr = response.body().string() - responseBodyStr == "Got: >${message}" - response.code() == 200 - - where: - message << ["foo", "bar", "baz"] - } -} diff --git a/dd-smoke-tests/spring-boot-rabbit/src/test/java/datadog/smoketest/SpringBootRabbitSmokeTest.java b/dd-smoke-tests/spring-boot-rabbit/src/test/java/datadog/smoketest/SpringBootRabbitSmokeTest.java new file mode 100644 index 00000000000..76204ad2d11 --- /dev/null +++ b/dd-smoke-tests/spring-boot-rabbit/src/test/java/datadog/smoketest/SpringBootRabbitSmokeTest.java @@ -0,0 +1,206 @@ +package datadog.smoketest; + +import static datadog.smoketest.trace.SpanMatcher.span; +import static datadog.smoketest.trace.TraceMatcher.SORT_BY_ANCESTRY; +import static datadog.smoketest.trace.TraceMatcher.trace; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import datadog.smoketest.backend.AgentBackend; +import datadog.smoketest.backend.TestAgentBackend; +import datadog.smoketest.backend.Traces; +import datadog.smoketest.trace.SpanMatcher; +import datadog.smoketest.trace.TraceMatcher; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.testcontainers.containers.RabbitMQContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +/** + * Two Spring Boot apps — a sender and a receiver — round-trip a message through RabbitMQ, and both + * report to a single shared test-agent backend, so one {@code @RegisterExtension} agent + * captures the distributed trace spanning both JVMs and the broker. + * + *

With {@code dd.rabbit.legacy.tracing.enabled=false} the whole round-trip is a single + * context-propagated trace — a strict parent→child chain of 12 spans, rooted at the sender {@code + * servlet.request}: + * + *

+ * spring-rabbit-0 servlet.request GET /roundtrip/{message}
+ *   spring-rabbit-0 spring.handler WebController.roundtrip
+ *     spring-rabbit-0 amqp.command  basic.publish -> otherqueue     (send)
+ *       rabbitmq        amqp.deliver amqp.deliver otherqueue
+ *         spring-rabbit-1 amqp.command basic.deliver otherqueue
+ *           spring-rabbit-1 amqp.consume amqp.consume otherqueue
+ *             spring-rabbit-1 spring.consume Receiver.receiveMessage  (receiver consumes)
+ *               spring-rabbit-1 amqp.command basic.publish -> queue    (receiver forwards reply)
+ *                 rabbitmq        amqp.deliver amqp.deliver queue
+ *                   spring-rabbit-0 amqp.command basic.deliver queue
+ *                     spring-rabbit-0 amqp.consume amqp.consume queue
+ *                       spring-rabbit-0 spring.consume Receiver.receiveMessage (sender consumes reply)
+ * 
+ * + *

The whole collection is asserted with {@link Traces#assertTraces} in order-independent subset + * mode ({@code unorder().ignoreAdditionalTraces()}): each matcher matches a distinct received trace + * and extras are ignored. Each round-trip trace is still matched count-exact (all 12 spans, in + * {@link TraceMatcher#SORT_BY_ANCESTRY ancestry order}), verifying every AMQP operation + * (publish/deliver/consume, both directions) and its cross-service linkage — while staying + * robust to the timing-dependent extras: the broker emits its connection-setup commands and per-ack + * traces as their own single-span traces, in non-deterministic count and order. + * + *

Two constraints these assertions depend on: + * + *

    + *
  • Ancestry order, not start time — the 12-span round-trip is a strict linear chain, + * but its spans start within the same tick and race, so {@code SORT_BY_START_TIME} is + * unstable across runs. {@code SORT_BY_ANCESTRY} orders each parent before its child + * (timestamp-independent along the chain), giving a stable positional order. + *
  • Accumulate, don't isolate — {@code retainAcrossTests()} keeps traces from app + * start-up onward, because {@code basic.qos}/{@code basic.consume}/{@code queue.declare} are + * emitted when the consumers start, before any test method, and a per-method session {@code + * clear()} would discard them. + *
+ */ +@Testcontainers +class SpringBootRabbitSmokeTest { + private static final int TIMEOUT_SECONDS = 60; + private static final int RABBIT_AMQP_PORT = 5672; + private static final OkHttpClient CLIENT = new OkHttpClient(); + // AMQP connection-setup / ack commands each app emits as its own (single-span) trace. + private static final String[] ADMIN_COMMANDS = { + "basic.qos", "basic.consume", "basic.ack", "queue.declare" + }; + + @Container + private static final RabbitMQContainer RABBIT = + new RabbitMQContainer(DockerImageName.parse("rabbitmq:3.9.20-alpine")); + + @Order(1) + @RegisterExtension + static final TestAgentBackend agent = AgentBackend.testAgentBuilder().retainAcrossTests().build(); + + @Order(2) + @RegisterExtension + static final SmokeServerApp sender = + rabbitApp(0).args("--rabbit.sender.queue=otherqueue").build(); + + @Order(3) + @RegisterExtension + static final SmokeServerApp receiver = + rabbitApp(1) + .args("--rabbit.receiver.queue=otherqueue", "--rabbit.receiver.forward=true") + .build(); + + @Test + void roundTripsProduceFullAmqpTraceStructure() throws IOException { + // Drive 3 round-trips through the sender; each travels + // sender -> otherqueue -> receiver -> queue -> sender. + String[] MESSAGES = {"foo", "bar", "baz"}; + for (String message : MESSAGES) { + Request request = + new Request.Builder().url(sender.url() + "/roundtrip/" + message).get().build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertEquals(200, response.code(), "round-trip " + message); + ResponseBody body = response.body(); + assertNotNull(body, "round-trip " + message + " response body is null"); + assertEquals("Got: >" + message, body.string(), "round-trip " + message); + } + } + + // One full round-trip trace per message, plus each service's connection-setup/ack commands. + List expected = new ArrayList<>(); + for (int i = 0; i < MESSAGES.length; i++) { + expected.add(roundTrip()); + } + for (String service : new String[] {"spring-rabbit-0", "spring-rabbit-1"}) { + for (String command : ADMIN_COMMANDS) { + expected.add(admin(service, command)); + } + } + agent + .traces() + .assertTraces( + TIMEOUT_SECONDS, + o -> o.unorder().ignoreAdditionalTraces(), + expected.toArray(new TraceMatcher[0])); + } + + // The full distributed round-trip: HTTP entrypoint -> publish -> receiver consumes and forwards + // -> sender consumes the reply. Each matcher after the root pins its parent to the preceding span + // with childOfPrevious(), so the chain asserts the cross-service linkage, not just the shape. + private static TraceMatcher roundTrip() { + return trace( + SORT_BY_ANCESTRY, + sp("spring-rabbit-0", "servlet.request", "GET /roundtrip/{message}").root(), + sp("spring-rabbit-0", "spring.handler", "WebController.roundtrip").childOfPrevious(), + sp("spring-rabbit-0", "amqp.command", "basic.publish -> otherqueue") + .childOfPrevious(), + sp("rabbitmq", "amqp.deliver", "amqp.deliver otherqueue").childOfPrevious(), + sp("spring-rabbit-1", "amqp.command", "basic.deliver otherqueue").childOfPrevious(), + sp("spring-rabbit-1", "amqp.consume", "amqp.consume otherqueue").childOfPrevious(), + sp("spring-rabbit-1", "spring.consume", "Receiver.receiveMessage").childOfPrevious(), + sp("spring-rabbit-1", "amqp.command", "basic.publish -> queue").childOfPrevious(), + sp("rabbitmq", "amqp.deliver", "amqp.deliver queue").childOfPrevious(), + sp("spring-rabbit-0", "amqp.command", "basic.deliver queue").childOfPrevious(), + sp("spring-rabbit-0", "amqp.consume", "amqp.consume queue").childOfPrevious(), + sp("spring-rabbit-0", "spring.consume", "Receiver.receiveMessage").childOfPrevious()); + } + + // A connection-setup / ack command emitted as its own single-span (root) trace. + private static TraceMatcher admin(String service, String command) { + return trace(sp(service, "amqp.command", command).root()); + } + + private static SpanMatcher sp(String service, String operation, String resource) { + SpanMatcher matcher = span().service(service).operationName(operation).resourceName(resource); + String type = spanType(operation); + if (type != null) { + matcher.type(type); + } + return matcher; + } + + private static String spanType(String operation) { + switch (operation) { + case "servlet.request": + case "spring.handler": + return "web"; + case "amqp.command": + case "amqp.deliver": + case "spring.consume": + return "queue"; + case "amqp.consume": + default: + return null; + } + } + + private static SmokeServerApp.Builder rabbitApp(int index) { + return SmokeServerApp.named("spring-rabbit-" + index) + .jar(System.getProperty("datadog.smoketest.springboot.shadowJar.path")) + .backend(agent) + .jvmArgs( + "-Ddd.service.name=spring-rabbit-" + index, "-Ddd.rabbit.legacy.tracing.enabled=false") + // Resolved at launch, after @Testcontainers has started RABBIT — not at build time. + .placeholder("rabbit.host", RABBIT::getHost) + .placeholder("rabbit.port", () -> String.valueOf(RABBIT.getMappedPort(RABBIT_AMQP_PORT))) + .args( + "--server.port=${app.httpPort}", + "--spring.rabbitmq.host=${rabbit.host}", + "--spring.rabbitmq.port=${rabbit.port}") + // The broker connection is torn down noisily when the app is killed at teardown. + .allowedErrorLogs( + "Failed to check/redeclare auto-delete queue(s)", + "An unexpected connection driver error occured"); + } +}