diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index df44494734c..762b371523f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1115,7 +1115,7 @@ test_debugger_arm64: - !reference [.test_job_arm64, script] test_smoke: - extends: .test_job + extends: .test_job_with_test_agent # needs:parallel:matrix limits this job to a specific build_tests combination. # Keep matrix vars exact and in build_tests declaration order: # https://docs.gitlab.com/ci/yaml/#needsparallelmatrix @@ -1135,7 +1135,7 @@ test_smoke: matrix: *test_matrix_8 test_smoke_arm64: - extends: .test_job_arm64 + extends: .test_job_arm64_with_test_agent variables: <<: *tier_l_variables GRADLE_TARGET: "stageMainDist :smokeTest" diff --git a/dd-smoke-tests/build.gradle b/dd-smoke-tests/build.gradle index 15e070ea84d..cec2a729cb0 100644 --- a/dd-smoke-tests/build.gradle +++ b/dd-smoke-tests/build.gradle @@ -11,8 +11,20 @@ dependencies { compileOnly(libs.junit.jupiter) + // Testcontainers backs the test-agent .container() backend (TestAgentBackend). Kept compileOnly + // so it is NOT forced onto the ~79 modules that depend on this base for the mock/.external() + // backends; modules using .container() opt in with their own testImplementation dependency. + compileOnly(libs.testcontainers) + compileOnly(libs.bundles.groovy) compileOnly(libs.bundles.spock) + + testImplementation(libs.testcontainers) +} + +tasks.withType(Test).configureEach { + // Cap concurrent Testcontainers usage across the build (TestAgentBackend container tests). + usesService(testcontainersLimit) } tasks.withType(GroovyCompile).configureEach { @@ -42,6 +54,13 @@ subprojects { Project subProj -> // Tests depend on this to know where to run things and what agent jar to use jvmArgs "-Ddatadog.smoketest.builddir=${buildDir}" + // Forward an optional test-agent image override to the test JVM, so TraceBackend.testAgent() + // runs locally without internal-registry access, e.g. + // -Ddatadog.smoketest(.testagent.image=ghcr.io/datadog/dd-apm-test-agent/ddapm-test-agent:v1.44.0 + def testAgentImage = System.getProperty("datadog.smoketest.testagent.image") + if (testAgentImage != null) { + jvmArgs "-Ddatadog.smoketest.testagent.image=${testAgentImage}" + } jvmArgumentProviders.add(new CommandLineArgumentProvider() { @Override Iterable asArguments() { diff --git a/dd-smoke-tests/gradle.lockfile b/dd-smoke-tests/gradle.lockfile index 5be43003ede..13823505b1d 100644 --- a/dd-smoke-tests/gradle.lockfile +++ b/dd-smoke-tests/gradle.lockfile @@ -13,6 +13,10 @@ com.datadoghq:dd-instrument-java:0.0.4=compileClasspath,runtimeClasspath,testCom com.datadoghq:dd-javac-plugin-client:0.2.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.datadoghq:java-dogstatsd-client:4.4.5=runtimeClasspath,testRuntimeClasspath com.datadoghq:sketches-java:0.8.3=runtimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.10.3=compileClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-api:3.4.2=compileClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.4.2=compileClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.4.2=compileClasspath,testCompileClasspath,testRuntimeClasspath com.github.javaparser:javaparser-core:3.25.6=codenarc com.github.jnr:jffi:1.3.15=runtimeClasspath,testRuntimeClasspath com.github.jnr:jnr-a64asm:1.0.0=runtimeClasspath,testRuntimeClasspath @@ -47,20 +51,22 @@ io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath io.sqreen:libsqreen:17.4.0=runtimeClasspath,testRuntimeClasspath javax.servlet:javax.servlet-api:3.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jaxen:jaxen:2.0.0=spotbugs -junit:junit:4.13.2=runtimeClasspath,testRuntimeClasspath +junit:junit:4.13.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy-agent:1.18.10=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.18.10=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna-platform:5.8.0=runtimeClasspath,testRuntimeClasspath -net.java.dev.jna:jna:5.8.0=runtimeClasspath,testRuntimeClasspath +net.java.dev.jna:jna:5.13.0=compileClasspath,testCompileClasspath,testRuntimeClasspath +net.java.dev.jna:jna:5.8.0=runtimeClasspath net.sf.saxon:Saxon-HE:12.9=spotbugs org.apache.ant:ant-antlr:1.10.14=codenarc org.apache.ant:ant-junit:1.10.14=codenarc org.apache.bcel:bcel:6.11.0=spotbugs +org.apache.commons:commons-compress:1.24.0=compileClasspath,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-lang3:3.19.0=spotbugs org.apache.commons:commons-text:1.14.0=spotbugs org.apache.logging.log4j:log4j-api:2.25.2=spotbugs org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apiguardian:apiguardian-api:1.1.2=compileClasspath,testCompileClasspath +org.apiguardian:apiguardian-api:1.1.2=compileClasspath,runtimeClasspath,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 @@ -73,10 +79,11 @@ org.codehaus.groovy:groovy:3.0.25=compileClasspath,testCompileClasspath,testRunt org.codenarc:CodeNarc:3.7.0=codenarc org.dom4j:dom4j:2.2.0=spotbugs org.gmetrics:GMetrics:2.1.0=codenarc -org.hamcrest:hamcrest-core:1.3=runtimeClasspath,testRuntimeClasspath +org.hamcrest:hamcrest-core:1.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.hamcrest:hamcrest:3.0=compileClasspath,testCompileClasspath,testRuntimeClasspath org.jctools:jctools-core-jdk11:4.0.6=runtimeClasspath,testRuntimeClasspath org.jctools:jctools-core:4.0.6=runtimeClasspath,testRuntimeClasspath +org.jetbrains:annotations:17.0.0=compileClasspath,testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:5.14.1=compileClasspath,testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath @@ -104,15 +111,18 @@ org.ow2.asm:asm-util:9.7.1=runtimeClasspath,testRuntimeClasspath org.ow2.asm:asm-util:9.9=spotbugs org.ow2.asm:asm:9.10.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.ow2.asm:asm:9.9=spotbugs +org.rnorth.duct-tape:duct-tape:1.0.8=compileClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:jcl-over-slf4j:1.7.30=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:1.7.30=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:log4j-over-slf4j:1.7.30=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:1.7.32=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:1.7.32=runtimeClasspath +org.slf4j:slf4j-api:1.7.36=compileClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j org.spockframework:spock-bom:2.4-groovy-3.0=compileClasspath,testCompileClasspath,testRuntimeClasspath org.spockframework:spock-core:2.4-groovy-3.0=compileClasspath,testCompileClasspath,testRuntimeClasspath org.tabletest:tabletest-junit:1.2.1=testCompileClasspath,testRuntimeClasspath org.tabletest:tabletest-parser:1.2.0=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:1.21.4=compileClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=spotbugs empty=annotationProcessor,spotbugsPlugins,testAnnotationProcessor diff --git a/dd-smoke-tests/opentelemetry/build.gradle b/dd-smoke-tests/opentelemetry/build.gradle index 26b9c62c717..3e3104466f7 100644 --- a/dd-smoke-tests/opentelemetry/build.gradle +++ b/dd-smoke-tests/opentelemetry/build.gradle @@ -15,9 +15,13 @@ dependencies { implementation group: 'io.opentelemetry', name: 'opentelemetry-api', version: '1.4.0' implementation group: 'io.opentelemetry.instrumentation', name: 'opentelemetry-instrumentation-annotations', version: '1.20.0' testImplementation project(':dd-smoke-tests') + // Needed to use TraceBackend.testAgent(): Testcontainers is compileOnly on the smoke-test base + // (so it isn't forced on every consumer), so a module using the container backend opts in here. + testImplementation libs.testcontainers } tasks.withType(Test).configureEach { + usesService(testcontainersLimit) dependsOn 'shadowJar' def shadowJarTask = tasks.named('shadowJar', ShadowJar) jvmArgumentProviders.add(new CommandLineArgumentProvider() { diff --git a/dd-smoke-tests/opentelemetry/gradle.lockfile b/dd-smoke-tests/opentelemetry/gradle.lockfile index 4c2601b6c61..6be0b0c2930 100644 --- a/dd-smoke-tests/opentelemetry/gradle.lockfile +++ b/dd-smoke-tests/opentelemetry/gradle.lockfile @@ -13,6 +13,10 @@ com.datadoghq:dd-instrument-java:0.0.4=testCompileClasspath,testRuntimeClasspath com.datadoghq:dd-javac-plugin-client:0.2.2=testCompileClasspath,testRuntimeClasspath com.datadoghq:java-dogstatsd-client:4.4.5=testRuntimeClasspath com.datadoghq:sketches-java:0.8.3=testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.10.3=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-api:3.4.2=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.4.2=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.4.2=testCompileClasspath,testRuntimeClasspath com.github.javaparser:javaparser-core:3.25.6=codenarc com.github.jnr:jffi:1.3.15=testRuntimeClasspath com.github.jnr:jnr-a64asm:1.0.0=testRuntimeClasspath @@ -50,20 +54,21 @@ io.opentelemetry:opentelemetry-context:1.19.0=compileClasspath,runtimeClasspath, io.sqreen:libsqreen:17.4.0=testRuntimeClasspath javax.servlet:javax.servlet-api:3.1.0=testCompileClasspath,testRuntimeClasspath jaxen:jaxen:2.0.0=spotbugs -junit:junit:4.13.2=testRuntimeClasspath +junit:junit:4.13.2=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy-agent:1.18.10=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.18.10=testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna-platform:5.8.0=testRuntimeClasspath -net.java.dev.jna:jna:5.8.0=testRuntimeClasspath +net.java.dev.jna:jna:5.13.0=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=spotbugs org.apache.ant:ant-antlr:1.10.14=codenarc org.apache.ant:ant-junit:1.10.14=codenarc org.apache.bcel:bcel:6.11.0=spotbugs +org.apache.commons:commons-compress:1.24.0=testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-lang3:3.19.0=spotbugs org.apache.commons:commons-text:1.14.0=spotbugs org.apache.logging.log4j:log4j-api:2.25.2=spotbugs org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -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 @@ -76,10 +81,11 @@ org.codehaus.groovy:groovy:3.0.25=testCompileClasspath,testRuntimeClasspath org.codenarc:CodeNarc:3.7.0=codenarc org.dom4j:dom4j:2.2.0=spotbugs org.gmetrics:GMetrics:2.1.0=codenarc -org.hamcrest:hamcrest-core:1.3=testRuntimeClasspath +org.hamcrest:hamcrest-core:1.3=testCompileClasspath,testRuntimeClasspath org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.jctools:jctools-core-jdk11:4.0.6=testRuntimeClasspath org.jctools:jctools-core:4.0.6=testRuntimeClasspath +org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath org.jspecify:jspecify:1.0.0=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath @@ -107,15 +113,17 @@ org.ow2.asm:asm-util:9.7.1=testRuntimeClasspath org.ow2.asm:asm-util:9.9=spotbugs org.ow2.asm:asm:9.10.1=testCompileClasspath,testRuntimeClasspath org.ow2.asm:asm:9.9=spotbugs +org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath org.slf4j:jcl-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath org.slf4j:jul-to-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath org.slf4j:log4j-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:1.7.32=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:1.7.36=testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j org.spockframework:spock-bom:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath org.spockframework:spock-core:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath org.tabletest:tabletest-junit:1.2.1=testCompileClasspath,testRuntimeClasspath org.tabletest:tabletest-parser:1.2.0=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:1.21.4=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=spotbugs empty=annotationProcessor,shadow,spotbugsPlugins,testAnnotationProcessor diff --git a/dd-smoke-tests/opentelemetry/src/test/groovy/datadog/smoketest/OpenTelemetrySmokeTest.groovy b/dd-smoke-tests/opentelemetry/src/test/groovy/datadog/smoketest/OpenTelemetrySmokeTest.groovy deleted file mode 100644 index f9c85720b0b..00000000000 --- a/dd-smoke-tests/opentelemetry/src/test/groovy/datadog/smoketest/OpenTelemetrySmokeTest.groovy +++ /dev/null @@ -1,27 +0,0 @@ -package datadog.smoketest - -import static java.util.concurrent.TimeUnit.SECONDS - -class OpenTelemetrySmokeTest extends AbstractSmokeTest { - public static final int TIMEOUT_SECS = 30 - - @Override - ProcessBuilder createProcessBuilder() { - def jarPath = System.getProperty("datadog.smoketest.shadowJar.path") - def command = new ArrayList() - command.add(javaPath()) - command.addAll(defaultJavaProperties) - command.add("-Ddd.trace.otel.enabled=true") - command.addAll(["-jar", jarPath]) - - ProcessBuilder processBuilder = new ProcessBuilder(command) - processBuilder.directory(new File(buildDirectory)) - } - - def 'receive trace'() { - expect: - waitForTraceCount(11) // 1 annotated, 10 manual - assert testedProcess.waitFor(TIMEOUT_SECS, SECONDS) - assert testedProcess.exitValue() == 0 - } -} diff --git a/dd-smoke-tests/opentelemetry/src/test/java/datadog/smoketest/OpenTelemetrySmokeTest.java b/dd-smoke-tests/opentelemetry/src/test/java/datadog/smoketest/OpenTelemetrySmokeTest.java new file mode 100644 index 00000000000..1fea4ff197b --- /dev/null +++ b/dd-smoke-tests/opentelemetry/src/test/java/datadog/smoketest/OpenTelemetrySmokeTest.java @@ -0,0 +1,45 @@ +package datadog.smoketest; + +import static datadog.smoketest.backend.TraceBackend.testAgent; +import static datadog.smoketest.trace.SpanMatcher.span; +import static datadog.smoketest.trace.TraceMatcher.trace; +import static java.util.concurrent.TimeUnit.SECONDS; + +import java.io.File; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class OpenTelemetrySmokeTest { + private static final int TIMEOUT_SECONDS = 30; + private static final File WORKING_DIRECTORY = + new File(System.getProperty("datadog.smoketest.builddir")); + private static final String APPLICATION_JAR = + System.getProperty("datadog.smoketest.shadowJar.path"); + + @RegisterExtension + static final SmokeCliApp app = + SmokeCliApp.named("opentelemetry") + .jar(APPLICATION_JAR) + .jvmArgs("-Ddd.trace.otel.enabled=true") + .workingDirectory(WORKING_DIRECTORY) + .backend(testAgent()) + .build(); + + @Test + void receivesTraces() { + app.traces() + .assertTraces( + trace(span().root().operationName("Application.annotatedSpan")), + trace(span().root().resourceName("span-0")), + trace(span().root().resourceName("span-1")), + trace(span().root().resourceName("span-2")), + trace(span().root().resourceName("span-3")), + trace(span().root().resourceName("span-4")), + trace(span().root().resourceName("span-5")), + trace(span().root().resourceName("span-6")), + trace(span().root().resourceName("span-7")), + trace(span().root().resourceName("span-8")), + trace(span().root().resourceName("span-9"))); + app.assertCompletesWithValue(TIMEOUT_SECONDS, SECONDS, 0); + } +} 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..639dfe5f7e3 --- /dev/null +++ b/dd-smoke-tests/spring-boot-rabbit/src/test/java/datadog/smoketest/SpringBootRabbitSmokeTest.java @@ -0,0 +1,198 @@ +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 datadog.smoketest.backend.EnabledIfDockerAvailable; +import datadog.smoketest.backend.TestAgentBackend; +import datadog.smoketest.backend.TraceBackend; +import datadog.smoketest.backend.Traces; +import datadog.smoketest.trace.SmokeTraceAssertions; +import datadog.smoketest.trace.SpanMatcher; +import datadog.smoketest.trace.TraceMatcher; +import datadog.trace.test.agent.decoder.DecodedSpan; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +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; + +/** + * S8 multi-app pilot — ported from the Groovy {@code SpringBootRabbitIntegrationTest}. Two Spring + * Boot apps (a sender and a receiver) round-trip a message through RabbitMQ and both report to one + * shared test-agent backend (Q8), so a single {@code @RegisterExtension} agent captures + * the distributed traces from both JVMs. + * + *

Replaces the dropped {@code TraceStructureWriter} file-mode (Q10) with {@link DecodedSpan} + * assertions, and hardens the Groovy {@code expectedTraces}. With {@code + * dd.rabbit.legacy.tracing.enabled=false} the whole round-trip is a single context-propagated trace + * spanning both JVMs and the broker — 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 the standard {@link Traces#assertTraces} DSL in + * {@linkplain SmokeTraceAssertions order-independent subset} mode ({@code + * unordered().ignoreAdditionalTraces()}): each matcher matches a distinct received trace and extras + * are ignored. This is stronger than the Groovy set-membership check — each round-trip trace is + * 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 design points, informed by inspecting the live trace collection: + * + *

    + *
  • 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 + * startup onward, because {@code basic.qos}/{@code basic.consume}/{@code queue.declare} are + * emitted when the consumers start (before any test method), which a per-method session + * {@code clear()} would discard. Mirrors the Groovy base verifying once at {@code + * cleanupSpec} against everything since launch. + *
+ */ +@EnabledIfDockerAvailable +@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 = + TraceBackend.testAgentBuilder().shared().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(), "roundtrip " + message); + assertEquals("Got: >" + message, response.body().string(), "roundtrip " + message); + } + } + + // One order-independent subset assertion over the whole collection (unordered + + // ignoreAdditionalTraces): each matcher must match a distinct received trace, and + // unrelated/duplicate traces (extra acks, etc.) are ignored. Assert 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 as one strict parent->child chain across both services and the + // broker. SORT_BY_ANCESTRY orders the spans parent->child (stable for a linear chain), and each + // matcher after the root pins its parent to the preceding span with childOfPrevious() — so the 12 + // count-exact positional matchers assert both the shape and the explicit parent linkage: HTTP + // entrypoint -> publish -> receiver consumes+forwards -> sender consumes reply. + 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) { + return span().service(service).operationName(operation).resourceName(resource); + } + + 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 occurred"); + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/AbstractSmokeApp.java b/dd-smoke-tests/src/main/java/datadog/smoketest/AbstractSmokeApp.java new file mode 100644 index 00000000000..16a087526b3 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/AbstractSmokeApp.java @@ -0,0 +1,611 @@ +package datadog.smoketest; + +import static java.util.concurrent.TimeUnit.SECONDS; + +import datadog.smoketest.backend.TraceBackend; +import datadog.smoketest.backend.Traces; +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeoutException; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; + +/** + * Base for a smoke-test application launched in its own JVM and managed as a JUnit 5 extension. + * Declare a concrete {@link SmokeServerApp} or {@link SmokeCliApp} as a {@code static + * @RegisterExtension} field: {@link #beforeAll} launches the app (and its owned {@link + * TraceBackend}) once per class, {@link #beforeEach} resets per method, and {@link #afterAll} tears + * everything down — no {@code @TestInstance(PER_CLASS)} required (Q7). Access the handle through + * the field or via {@link ParameterResolver} injection of the app / {@link Traces} / {@link + * TraceBackend} into a test method (Q7). + * + *

This base owns everything common to both app shapes — process launch, log capture, the + * no-error-logs and telemetry teardown checks, backend wiring, and the fluent {@link Builder}. The + * two subclasses differ only in start-up readiness and per-method reset ({@link #onStarted()} / + * {@link #onBeforeEach()}) and in their shape-specific API: + * + *

    + *
  • {@link SmokeServerApp} — a long-running HTTP server: adds {@code httpPort()}/{@code + * url()}/{@code get(...)}, waits for its port on start-up, and resets the backend between + * methods. + *
  • {@link SmokeCliApp} — a batch/CLI app that runs to completion: adds {@code + * assertCompletesWithValue(...)}. + *
+ * + *

Core launch capabilities are reproduced here (Q6); several are deliberately deferred and + * marked with explicit {@code // TODO}s so the gaps are discoverable rather than silent. + */ +public abstract class AbstractSmokeApp + implements BeforeAllCallback, + AfterAllCallback, + BeforeEachCallback, + AfterEachCallback, + ParameterResolver { + + // Defaults mirroring the Groovy ProcessManager base so ported tests behave the same. + private static final String SERVICE_NAME = "smoke-test-java-app"; + private static final String ENV = "smoketest"; + private static final String VERSION = "99"; + private static final String API_KEY = "01234567890abcdef123456789ABCDEF"; + private static final Set NOISY_ENVIRONMENT_VARIABLES = + new HashSet<>(Arrays.asList("CI_COMMIT_TITLE", "CI_COMMIT_MESSAGE", "CI_COMMIT_DESCRIPTION")); + private static final String AGENT_JAR_PROPERTY = "datadog.smoketest.agent.shadowJar.path"; + private static final String BUILD_DIR_PROPERTY = "datadog.smoketest.builddir"; + + private final String name; + private final String jar; + private final String mainClass; + private final String classpath; + private final List jvmArgs; + private final List programArgs; + private final Map> placeholders; + private final Map extraEnv; + private final File workingDirectory; + private final TraceBackend backend; + private final boolean ownsBackend; + private final String agentJar; // null => launch without -javaagent + private final long startupTimeoutSeconds; + private final Predicate errorLogFilter; + private final boolean checkErrorLogs; + private final boolean checkTelemetry; + + private final OutputThreads outputThreads = new OutputThreads(); + private Process process; + private File logFile; + private boolean telemetryChecked; + + protected AbstractSmokeApp(Builder builder) { + this.name = builder.name; + this.jar = builder.jar; + this.mainClass = builder.mainClass; + this.classpath = + builder.classpath != null ? builder.classpath : System.getProperty("java.class.path"); + this.jvmArgs = new ArrayList<>(builder.jvmArgs); + this.programArgs = new ArrayList<>(builder.programArgs); + this.placeholders = new LinkedHashMap<>(builder.placeholders); + this.extraEnv = new HashMap<>(builder.extraEnv); + this.workingDirectory = builder.workingDirectory; + this.backend = builder.backend; + this.ownsBackend = !builder.backend.isShared(); + this.agentJar = builder.resolveAgentJar(); + this.startupTimeoutSeconds = builder.startupTimeoutSeconds; + this.checkErrorLogs = builder.checkErrorLogs; + this.checkTelemetry = builder.checkTelemetry; + this.errorLogFilter = + builder.errorLogFilter != null + ? builder.errorLogFilter + : defaultErrorLogFilter(builder.allowedErrorLogs); + } + + // --- Handle API (field access) --- + + /** The trace query/assert facade of this app's backend. */ + public Traces traces() { + return this.backend.traces(); + } + + /** The backend this app sends traces to. */ + public TraceBackend backend() { + return this.backend; + } + + /** + * Waits (up to the log helper's timeout) for a captured stdout/stderr line matching {@code + * predicate}; returns {@code false} on timeout. Lines are reset per test method. + */ + public boolean awaitLogLine(Function predicate) { + try { + return this.outputThreads.processTestLogLines(predicate); + } catch (TimeoutException e) { + return false; + } + } + + // --- Shared state exposed to subclasses (start-up / per-method hooks) --- + + /** The app's (log/diagnostic) name. */ + protected final String name() { + return this.name; + } + + /** The launched process, or {@code null} before {@link #beforeAll}. */ + protected final Process process() { + return this.process; + } + + /** How long start-up may wait for the app to become ready. */ + protected final long startupTimeoutSeconds() { + return this.startupTimeoutSeconds; + } + + /** Whether this app owns its backend's lifecycle (i.e. the backend is not shared, S6). */ + protected final boolean ownsBackend() { + return this.ownsBackend; + } + + /** + * Registers an additional launch-time placeholder (e.g. a subclass's {@code ${app.httpPort}}). + */ + protected final void registerPlaceholder(String token, Supplier value) { + this.placeholders.put(token, value); + } + + // --- Lifecycle (per-class start, per-method reset, teardown) --- + + @Override + public final void beforeAll(ExtensionContext context) throws Exception { + this.backend.start(); + launch(); + onStarted(); + } + + @Override + public final void beforeEach(ExtensionContext context) { + onBeforeEach(); + this.outputThreads.clearMessages(); + } + + @Override + public final void afterEach(ExtensionContext context) { + // Check telemetry once, here, while the app and backend are still up — afterAll is too late + // (the + // app is killed, and the per-method session clear may have wiped a once-only app-started). Only + // for agent-instrumented apps (a no-agent app emits none). + if (this.checkTelemetry && this.agentJar != null && !this.telemetryChecked) { + this.telemetryChecked = true; + assertTelemetryReceived(); + } + } + + @Override + public final void afterAll(ExtensionContext context) { + try { + stopProcess(); + } finally { + // Join the output threads first so the log file is fully flushed before we scan it. + this.outputThreads.close(); + try { + if (this.ownsBackend) { + this.backend.close(); + } + } finally { + if (this.checkErrorLogs) { + assertNoErrorLogs(); + } + } + } + } + + /** + * Invoked once right after the app process is launched (in {@link #beforeAll}). Subclasses assert + * their notion of "ready": a server waits for its port, a batch app fails fast on abnormal exit. + */ + protected abstract void onStarted() throws Exception; + + /** + * Invoked before each test method (in {@link #beforeEach}), before the captured log is reset. The + * default is a no-op (a batch app may already have completed); a server asserts it's alive and + * resets the backend. + */ + protected void onBeforeEach() {} + + private void launch() throws IOException { + List command = new ArrayList<>(); + command.add(javaExecutable()); + + if (this.agentJar != null) { + command.add("-javaagent:" + this.agentJar); + command.add("-Ddd.trace.agent.host=" + this.backend.url().getHost()); + command.add("-Ddd.trace.agent.port=" + this.backend.port()); + command.add("-Ddd.service.name=" + SERVICE_NAME); + command.add("-Ddd.env=" + ENV); + command.add("-Ddd.version=" + VERSION); + String sessionToken = this.backend.sessionToken(); + if (sessionToken != null) { + command.add("-Ddd.test.agent.session.token=" + sessionToken); + } + if (this.checkTelemetry) { + // Emit telemetry promptly so app-started is captured before a (long-running server) app is + // killed at teardown — mirrors the Groovy base's telemetry tests. + command.add("-Ddd.telemetry.heartbeat.interval=1"); + } + } + for (String jvmArg : this.jvmArgs) { + command.add(substitute(jvmArg)); + } + if (this.jar != null) { + command.add("-jar"); + command.add(this.jar); + } else { + command.add("-cp"); + command.add(this.classpath); + command.add(this.mainClass); + } + for (String programArg : this.programArgs) { + command.add(substitute(programArg)); + } + + ProcessBuilder processBuilder = new ProcessBuilder(command); + if (this.workingDirectory != null) { + processBuilder.directory(this.workingDirectory); + } + Map env = processBuilder.environment(); + env.put("JAVA_HOME", System.getProperty("java.home")); + env.put("DD_API_KEY", API_KEY); + env.keySet().removeAll(NOISY_ENVIRONMENT_VARIABLES); + env.putAll(this.extraEnv); + processBuilder.redirectErrorStream(true); + + this.logFile = resolveLogFile(); + this.process = processBuilder.start(); + this.outputThreads.captureOutput(this.process, this.logFile); + } + + private void stopProcess() { + if (this.process == null) { + return; + } + if (!this.process.isAlive()) { + return; + } + this.process.destroy(); + try { + if (!this.process.waitFor(5, SECONDS)) { + this.process.destroyForcibly(); + this.process.waitFor(10, SECONDS); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + this.process.destroyForcibly(); + } + } + + private String substitute(String value) { + String result = value; + for (Map.Entry> placeholder : this.placeholders.entrySet()) { + String token = placeholder.getKey(); + if (result.contains(token)) { + // Resolved now (at launch), not when registered — the value's source (e.g. a container's + // mapped port) may not exist until test infrastructure has started. + result = result.replace(token, placeholder.getValue().get()); + } + } + return result; + } + + private File resolveLogFile() { + String buildDir = System.getProperty(BUILD_DIR_PROPERTY); + File dir = + buildDir != null + ? new File(buildDir, "reports") + : new File(System.getProperty("java.io.tmpdir")); + dir.mkdirs(); + // TODO Q6 (deferred): retry-safe timestamped log file names so retries don't clobber prior + // logs. + return new File(dir, "smoke-app." + this.name + ".log"); + } + + /** + * Asserts the app logged no error lines, per the configured filter. Reads the whole captured log + * (everything since launch), so it mirrors the Groovy base's universal no-error-logs check. + * Auto-invoked at teardown unless {@link Builder#skipErrorLogCheck()} was set; may also be called + * explicitly mid-run. + */ + public void assertNoErrorLogs() { + if (this.logFile == null) { + return; // never launched / nothing captured + } + List errors = new ArrayList<>(); + try (BufferedReader reader = + Files.newBufferedReader(this.logFile.toPath(), StandardCharsets.UTF_8)) { + String line; + while ((line = reader.readLine()) != null) { + if (this.errorLogFilter.test(line)) { + errors.add(line); + } + } + } catch (NoSuchFileException e) { + return; // no output file was produced + } catch (IOException e) { + throw new IllegalStateException("Failed to read app log " + this.logFile, e); + } + if (!errors.isEmpty()) { + StringBuilder message = + new StringBuilder("App '") + .append(this.name) + .append("' logged ") + .append(errors.size()) + .append(" error line(s):"); + for (String error : errors) { + message.append("\n ").append(error); + } + throw new AssertionError(message.toString()); + } + } + + /** + * Asserts the app's telemetry pipeline is active — at least one telemetry message reached the + * backend. Auto-invoked once at the first {@link #afterEach} (while the app + backend are still + * up) unless {@link Builder#skipTelemetryCheck()}. It intentionally asserts "telemetry is + * flowing" rather than a specific event: the once-only {@code app-started} is fragile under the + * per-method session clear, whereas heartbeats keep arriving; a test wanting a specific event can + * assert it with {@code backend().telemetry().waitForFlat(...)}. + */ + public void assertTelemetryReceived() { + this.backend.telemetry().waitForCount(1, this.startupTimeoutSeconds); + } + + /** + * The default error-log predicate: a line is an error if it contains {@code ERROR}, {@code + * ASSERTION FAILED}, or {@code Failed to handle exception in instrumentation} — unless it + * contains one of the {@code allowed} substrings (the allowlist escape hatch). Package-private + * for testing. + */ + static Predicate defaultErrorLogFilter(List allowed) { + List allowlist = new ArrayList<>(allowed); + return line -> { + for (String allowedSubstring : allowlist) { + if (line.contains(allowedSubstring)) { + return false; + } + } + return line.contains("ERROR") + || line.contains("ASSERTION FAILED") + || line.contains("Failed to handle exception in instrumentation"); + }; + } + + private static String javaExecutable() { + return System.getProperty("java.home") + File.separator + "bin" + File.separator + "java"; + } + + // --- ParameterResolver (injection by type; qualifier for multi-instance is deferred) --- + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext context) { + Class type = parameterContext.getParameter().getType(); + return type.isInstance(this) || type == Traces.class || type == TraceBackend.class; + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext context) { + Class type = parameterContext.getParameter().getType(); + if (type.isInstance(this)) { + return this; + } + if (type == Traces.class) { + return traces(); + } + if (type == TraceBackend.class) { + return this.backend; + } + throw new ParameterResolutionException("Cannot resolve parameter of type " + type); + // TODO Q7: with multiple same-type apps/backends injection is ambiguous — add a qualifier + // annotation (e.g. @App("producer")) when a multi-app test needs parameter injection (S6). + } + + /** + * Fluent builder shared by the concrete apps. Self-typed ({@code B}) so every setter returns the + * concrete builder for chaining; {@code build()} returns the concrete app ({@code A}). Obtain one + * via {@link SmokeServerApp#named(String)} or {@link SmokeCliApp#named(String)}. + */ + public abstract static class Builder> { + private final String name; + private String jar; + private String mainClass; + private String classpath; + private final List jvmArgs = new ArrayList<>(); + private final List programArgs = new ArrayList<>(); + private final Map> placeholders = new LinkedHashMap<>(); + private final Map extraEnv = new HashMap<>(); + private File workingDirectory; + private TraceBackend backend; + private String explicitAgentJar; + private boolean noAgent; + private long startupTimeoutSeconds = 120; + private Predicate errorLogFilter; + private final List allowedErrorLogs = new ArrayList<>(); + private boolean checkErrorLogs = true; + private boolean checkTelemetry = true; + + protected Builder(String name) { + this.name = name; + } + + /** Returns {@code this} as the concrete builder type, for fluent chaining. */ + protected abstract B self(); + + /** Builds the concrete app. Implementations must call {@link #validate()} first. */ + public abstract A build(); + + /** Runs {@code java -jar }. Mutually exclusive with {@link #mainClass(String)}. */ + public B jar(String jarPath) { + this.jar = jarPath; + return self(); + } + + /** Runs {@code java -cp } (classpath defaults to the current one). */ + public B mainClass(String mainClass) { + this.mainClass = mainClass; + return self(); + } + + /** Classpath for {@link #mainClass(String)} mode; defaults to the launching JVM's classpath. */ + public B classpath(String classpath) { + this.classpath = classpath; + return self(); + } + + /** + * Program arguments (after the jar/main class). Supports launch-time {@code ${...}} + * placeholders (see {@link #placeholder(String, Supplier)}); {@link SmokeServerApp} also + * provides {@code ${app.httpPort}}. + */ + public B args(String... args) { + this.programArgs.addAll(Arrays.asList(args)); + return self(); + } + + /** + * Extra JVM arguments (before the jar/main class). Supports the same launch-time {@code ${...}} + * placeholders as {@link #args(String...)}. + */ + public B jvmArgs(String... jvmArgs) { + this.jvmArgs.addAll(Arrays.asList(jvmArgs)); + return self(); + } + + /** + * Registers a launch-time placeholder: occurrences of ${name} in {@link + * #jvmArgs(String...) jvmArgs} and {@link #args(String...) args} are replaced with {@code + * value.get()} when the app launches (in {@code beforeAll}), not when the builder + * runs. Use for values only known once test infrastructure has started — e.g. a Testcontainers + * mapped port, which is unavailable when the {@code static @RegisterExtension} fields + * initialize: + * + *

{@code
+     * .placeholder("rabbit.port", () -> String.valueOf(RABBIT.getMappedPort(5672)))
+     * .args("--spring.rabbitmq.port=${rabbit.port}")
+     * }
+ */ + public B placeholder(String name, Supplier value) { + this.placeholders.put("${" + name + "}", value); + return self(); + } + + /** Sets an environment variable for the launched process (applied after the defaults). */ + public B env(String key, String value) { + this.extraEnv.put(key, value); + return self(); + } + + /** Working directory for the launched process. */ + public B workingDirectory(File workingDirectory) { + this.workingDirectory = workingDirectory; + return self(); + } + + /** + * The backend the app sends traces to; started/stopped by this app unless it is shared (S6). + */ + public B backend(TraceBackend backend) { + this.backend = backend; + return self(); + } + + /** + * Overrides the agent jar (default: the {@code datadog.smoketest.agent.shadowJar.path} + * property). + */ + public B javaAgent(String agentJarPath) { + this.explicitAgentJar = agentJarPath; + return self(); + } + + /** Launches the app without {@code -javaagent} (e.g. for launch-mechanics tests). */ + public B noAgent() { + this.noAgent = true; + return self(); + } + + /** How long start-up waits for the app to become ready (default 120s). */ + public B startupTimeoutSeconds(long seconds) { + this.startupTimeoutSeconds = seconds; + return self(); + } + + /** + * Overrides how a captured log line is judged an error (default: contains {@code ERROR} / + * {@code ASSERTION FAILED} / an instrumentation-exception marker). Replaces the allowlist. + */ + public B errorLogFilter(Predicate isError) { + this.errorLogFilter = isError; + return self(); + } + + /** Allowlists log lines containing any of these substrings from the default error-log check. */ + public B allowedErrorLogs(String... substrings) { + this.allowedErrorLogs.addAll(Arrays.asList(substrings)); + return self(); + } + + /** Disables the automatic no-error-logs check at teardown (e.g. for error-case tests). */ + public B skipErrorLogCheck() { + this.checkErrorLogs = false; + return self(); + } + + /** + * Disables the automatic app-started telemetry check at teardown (for agent apps that run with + * telemetry disabled, e.g. {@code dd.instrumentation.telemetry.enabled=false}). + */ + public B skipTelemetryCheck() { + this.checkTelemetry = false; + return self(); + } + + private String resolveAgentJar() { + if (this.noAgent) { + return null; + } + return this.explicitAgentJar != null + ? this.explicitAgentJar + : System.getProperty(AGENT_JAR_PROPERTY); + } + + /** Validates common invariants; concrete {@link #build()} implementations must call this. */ + protected void validate() { + if (this.backend == null) { + throw new IllegalStateException("A TraceBackend is required — call backend(...)"); + } + if ((this.jar == null) == (this.mainClass == null)) { + throw new IllegalStateException("Exactly one of jar(...) or mainClass(...) must be set"); + } + // TODO Q6 (deferred, opt-in mixins / .jvmArgs(...) escape hatch — not baked into the base): + // profiling args; crash-tracking args (-XX:OnError=...dd_crash_uploader.sh); memory tuning + // (ForkedTestUtils); retry log-file timestamping. Add as explicit opt-ins when a ported test + // needs them. + } + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/SmokeCliApp.java b/dd-smoke-tests/src/main/java/datadog/smoketest/SmokeCliApp.java new file mode 100644 index 00000000000..50cbd78ccdd --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/SmokeCliApp.java @@ -0,0 +1,87 @@ +package datadog.smoketest; + +import java.util.concurrent.TimeUnit; + +/** + * A batch/CLI smoke app that runs to completion (rather than staying up as a server). On start-up + * it only fails fast if the process has already exited abnormally; there is no port to wait for and + * no per-method backend reset (a batch app may have produced all its traces at start-up, so + * clearing between methods would discard them). + * + *
{@code
+ * @RegisterExtension
+ * static final SmokeCliApp app = SmokeCliApp.named("opentelemetry")
+ *     .jar(System.getProperty("datadog.smoketest.shadowJar.path"))
+ *     .backend(TraceBackend.testAgent())
+ *     .build();
+ *
+ * @Test
+ * void runsToCompletion() {
+ *   app.traces().waitForTraceCount(11, 30);
+ *   app.assertCompletesWithValue(30, SECONDS, 0);
+ * }
+ * }
+ */ +public final class SmokeCliApp extends AbstractSmokeApp { + + private SmokeCliApp(Builder builder) { + super(builder); + } + + /** Starts a fluent builder for a batch/CLI app with the given (log/diagnostic) name. */ + public static Builder named(String name) { + return new Builder(name); + } + + /** + * Asserts the app runs to completion within the timeout and exits with {@code expectedExitValue}. + * Fails with an {@link AssertionError} if it doesn't terminate in time or exits with a different + * code. Pass a non-zero value for apps expected to fail (e.g. a tool the agent aborts). + */ + public void assertCompletesWithValue(long timeout, TimeUnit unit, int expectedExitValue) { + boolean exited; + try { + exited = process().waitFor(timeout, unit); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while waiting for app '" + name() + "' to complete", e); + } + if (!exited) { + throw new AssertionError( + "App '" + name() + "' did not complete within " + timeout + " " + unit); + } + int actual = process().exitValue(); + if (actual != expectedExitValue) { + throw new AssertionError( + "App '" + name() + "' exited with " + actual + " but expected " + expectedExitValue); + } + } + + @Override + protected void onStarted() { + // A batch app may already have run to completion at start-up; only fail fast if it exited + // abnormally. (Server-style port waiting doesn't apply.) + if (!process().isAlive() && process().exitValue() != 0) { + throw new IllegalStateException( + "App '" + name() + "' exited abnormally on start (exit " + process().exitValue() + ")"); + } + } + + /** Fluent builder for a {@link SmokeCliApp}; obtain via {@link SmokeCliApp#named(String)}. */ + public static final class Builder extends AbstractSmokeApp.Builder { + private Builder(String name) { + super(name); + } + + @Override + protected Builder self() { + return this; + } + + @Override + public SmokeCliApp build() { + validate(); + return new SmokeCliApp(this); + } + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/SmokeServerApp.java b/dd-smoke-tests/src/main/java/datadog/smoketest/SmokeServerApp.java new file mode 100644 index 00000000000..f170593fee2 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/SmokeServerApp.java @@ -0,0 +1,112 @@ +package datadog.smoketest; + +import static datadog.trace.agent.test.utils.PortUtils.waitForPortToOpen; +import static java.util.concurrent.TimeUnit.SECONDS; + +import datadog.trace.agent.test.utils.PortUtils; +import datadog.trace.api.internal.VisibleForTesting; +import java.io.IOException; +import java.net.URI; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +/** + * A long-running HTTP-server smoke app: it stays up across test methods, serving requests the test + * body drives. On start-up it waits for its (randomly-allocated) port to open, and between methods + * it asserts the process is still alive and resets its owned backend so each method sees only its + * own traces. + * + *

The allocated port is exposed as {@link #httpPort()} and substituted into launch args via the + * {@code ${app.httpPort}} placeholder: + * + *

{@code
+ * @RegisterExtension
+ * static final SmokeServerApp app = SmokeServerApp.named("springboot")
+ *     .jar(System.getProperty("datadog.smoketest.springboot.shadowJar.path"))
+ *     .args("--server.port=${app.httpPort}")
+ *     .backend(TraceBackend.mockAgent())
+ *     .build();
+ * }
+ */ +public final class SmokeServerApp extends AbstractSmokeApp { + private static final String HTTP_PORT_PLACEHOLDER = "${app.httpPort}"; + + private final int httpPort; + private final OkHttpClient httpClient = new OkHttpClient(); + + private SmokeServerApp(Builder builder) { + super(builder); + this.httpPort = PortUtils.randomOpenPort(); + registerPlaceholder(HTTP_PORT_PLACEHOLDER, () -> Integer.toString(this.httpPort)); + } + + /** Starts a fluent builder for a server app with the given (log/diagnostic) name. */ + public static Builder named(String name) { + return new Builder(name); + } + + /** + * The randomly-allocated port the app should bind (substituted for {@value + * #HTTP_PORT_PLACEHOLDER}). + */ + public int httpPort() { + return this.httpPort; + } + + /** Base URL of the app's HTTP server. */ + public URI url() { + return URI.create("http://localhost:" + this.httpPort); + } + + /** + * Issues a GET to the app and returns the HTTP status code (the response is drained and closed). + */ + @VisibleForTesting + int get(String path) { + String full = url() + (path.startsWith("/") ? path : "/" + path); + Request request = new Request.Builder().url(full).get().build(); + try (Response response = this.httpClient.newCall(request).execute()) { + return response.code(); + } catch (IOException e) { + throw new IllegalStateException("GET " + full + " failed", e); + } + } + + @Override + protected void onStarted() { + waitForPortToOpen(this.httpPort, startupTimeoutSeconds(), SECONDS, process()); + } + + @Override + protected void onBeforeEach() { + // A server must stay up across methods, and its per-test traces are produced during the test + // body — so assert it's alive and reset the (owned) backend between methods. + if (process() == null || !process().isAlive()) { + throw new IllegalStateException("App '" + name() + "' is not alive at the start of a test"); + } + if (ownsBackend()) { + backend().clear(); + } + } + + /** + * Fluent builder for a {@link SmokeServerApp}; obtain via {@link SmokeServerApp#named(String)}. + */ + public static final class Builder extends AbstractSmokeApp.Builder { + private Builder(String name) { + super(name); + } + + @Override + protected Builder self() { + return this; + } + + @Override + public SmokeServerApp build() { + validate(); + return new SmokeServerApp(this); + } + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/backend/DockerAvailableCondition.java b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/DockerAvailableCondition.java new file mode 100644 index 00000000000..b1ef0484406 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/DockerAvailableCondition.java @@ -0,0 +1,47 @@ +package datadog.smoketest.backend; + +import static org.junit.jupiter.api.extension.ConditionEvaluationResult.disabled; +import static org.junit.jupiter.api.extension.ConditionEvaluationResult.enabled; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.testcontainers.DockerClientFactory; + +/** + * JUnit {@link ExecutionCondition} backing {@link EnabledIfDockerAvailable}. Docker availability is + * probed once per JVM and cached, since the probe can be slow. + */ +final class DockerAvailableCondition implements ExecutionCondition { + private static volatile Boolean dockerAvailable; + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + if (isDockerAvailable()) { + return enabled("Docker is available"); + } + return disabled( + "Docker is not available — skipping test-agent container test. Start Docker to run it " + + "locally, or reuse a CI test agent via CI_AGENT_HOST."); + } + + private static boolean isDockerAvailable() { + Boolean cached = dockerAvailable; + if (cached == null) { + synchronized (DockerAvailableCondition.class) { + cached = dockerAvailable; + if (cached == null) { + try { + cached = DockerClientFactory.instance().isDockerAvailable(); + } catch (Throwable t) { + // Any failure probing the daemon (Testcontainers/docker-java errors) => treat as + // absent. + cached = false; + } + dockerAvailable = cached; + } + } + } + return cached; + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/backend/EnabledIfDockerAvailable.java b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/EnabledIfDockerAvailable.java new file mode 100644 index 00000000000..e9c04ae4ced --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/EnabledIfDockerAvailable.java @@ -0,0 +1,24 @@ +package datadog.smoketest.backend; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Skips (loudly, as a JUnit conditional-execution result — not a failure) the annotated test class + * or method when Docker is unavailable, so a developer not running smoke tests locally isn't + * blocked (Q4/S7/G7). Apply it to tests that require a Testcontainers-managed test-agent {@code + * .container()} backend. + * + *

Tests that select their backend from the environment via {@code TraceBackend.testAgent()} + * don't need this annotation — that resolver already reuses an external CI agent when {@code + * CI_AGENT_HOST} is set, and aborts loudly otherwise. + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@ExtendWith(DockerAvailableCondition.class) +public @interface EnabledIfDockerAvailable {} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/backend/MockAgentBackend.java b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/MockAgentBackend.java new file mode 100644 index 00000000000..530ee3cc2f7 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/MockAgentBackend.java @@ -0,0 +1,154 @@ +package datadog.smoketest.backend; + +import datadog.trace.agent.test.server.http.JavaTestHttpServer; +import datadog.trace.agent.test.server.http.JavaTestHttpServer.HandlerApi; +import datadog.trace.test.agent.decoder.DecodedMessage; +import datadog.trace.test.agent.decoder.DecodedTrace; +import datadog.trace.test.agent.decoder.Decoder; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * In-process mock-agent {@link TraceBackend} wrapping the testing {@link JavaTestHttpServer}. It + * answers the tracer's {@code /info} probe, decodes the trace payloads the tracer submits (v0.4 / + * v0.5 / v1.0 msgpack) via the shared {@link Decoder}, and 200s everything else so the app's other + * agent calls (telemetry, remote-config, ...) don't error out. + * + *

Backend-specific capture surfaces (remote-config / EVP-proxy / DSM — Q10/S10) will hang off + * this concrete type rather than the common {@link TraceBackend} facade. + */ +public final class MockAgentBackend implements TraceBackend { + // Minimal agent-info payload advertising the trace endpoints, mirroring the real agent's /info + // (see SimpleAgentMock). The tracer negotiates its trace encoding from this list. + private static final String INFO_BODY = + "{\"version\":\"7.77.0\",\"endpoints\":[\"/v0.4/traces\",\"/v0.5/traces\",\"/v1.0/traces\"]}"; + private static final String JSON = "application/json"; + + private final List traces = new CopyOnWriteArrayList<>(); + private final List> telemetry = new CopyOnWriteArrayList<>(); + private volatile JavaTestHttpServer server; + private boolean shared; + + MockAgentBackend() {} + + /** + * Marks this backend as shared across multiple apps — declare it as its own + * {@code @RegisterExtension} field so it is started/reset/closed once rather than by each app + * (S6/Q8). + */ + public MockAgentBackend shared() { + this.shared = true; + return this; + } + + @Override + public boolean isShared() { + return this.shared; + } + + @Override + public void start() { + if (this.server != null) { + return; + } + this.server = + JavaTestHttpServer.httpServer( + s -> + s.handlers( + h -> { + // Trace endpoints are method-agnostic prefix handlers: the tracer submits + // traces with PUT (DDAgentApi), not POST. + h.prefix("/info", this::sendInfo); + h.prefix("/v1.0/traces", api -> collect(api, TraceFormat.V1)); + h.prefix("/v0.5/traces", api -> collect(api, TraceFormat.V05)); + h.prefix("/v0.4/traces", api -> collect(api, TraceFormat.V04)); + h.prefix("/telemetry/proxy", this::collectTelemetry); + // Everything else (remote-config, ...) just succeeds. + h.all(api -> api.getResponse().status(200).send()); + })); + } + + private void sendInfo(HandlerApi api) { + api.getResponse().status(200).sendWithType(JSON, INFO_BODY); + } + + private void collect(HandlerApi api, TraceFormat format) { + DecodedMessage message = format.decode(api.getRequest().getBody()); + this.traces.addAll(message.getTraces()); + api.getResponse().status(200).sendWithType(JSON, "{}"); + } + + private void collectTelemetry(HandlerApi api) { + byte[] body = api.getRequest().getBody(); + if (body != null && body.length > 0) { + this.telemetry.add(TelemetryDecoder.decodeMessage(body)); + } + api.getResponse().status(202).send(); + } + + @Override + public int port() { + return url().getPort(); + } + + @Override + public URI url() { + JavaTestHttpServer running = this.server; + if (running == null) { + throw new IllegalStateException("MockAgentBackend not started — call start() first"); + } + return running.getAddress(); + } + + @Override + public Traces traces() { + return new Traces(() -> new ArrayList<>(this.traces)); + } + + @Override + public Telemetry telemetry() { + return new Telemetry(() -> new ArrayList<>(this.telemetry)); + } + + @Override + public void clear() { + this.traces.clear(); + this.telemetry.clear(); + } + + @Override + public void close() { + JavaTestHttpServer running = this.server; + if (running != null) { + this.server = null; + running.close(); + } + } + + /** The msgpack trace-payload formats the mock agent accepts, each decoded by {@link Decoder}. */ + private enum TraceFormat { + V04 { + @Override + DecodedMessage decode(byte[] body) { + return Decoder.decodeV04(body); + } + }, + V05 { + @Override + DecodedMessage decode(byte[] body) { + return Decoder.decodeV05(body); + } + }, + V1 { + @Override + DecodedMessage decode(byte[] body) { + return Decoder.decodeV1(body); + } + }; + + abstract DecodedMessage decode(byte[] body); + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/backend/Telemetry.java b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/Telemetry.java new file mode 100644 index 00000000000..a36037d4292 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/Telemetry.java @@ -0,0 +1,91 @@ +package datadog.smoketest.backend; + +import datadog.trace.test.util.PollingConditions; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** + * Query facade over the app-telemetry messages a {@link TraceBackend} has received (S9). Common to + * both backends: the mock collects them in-process, the test agent exposes them at {@code + * /test/session/apmtelemetry}; each message is a decoded JSON map. + * + *

Telemetry is scoped to the test session (the tracer's telemetry client emits the same {@code + * X-Datadog-Test-Session-Token} as the trace writer), so it stays isolated per test even on a + * shared external agent. + */ +public final class Telemetry { + private static final double DEFAULT_TIMEOUT_SECONDS = 10; + + private final Supplier>> source; + + Telemetry(Supplier>> source) { + this.source = source; + } + + /** The raw telemetry messages received — one map per intake request. */ + public List> getMessages() { + return this.source.get(); + } + + /** + * Individual telemetry events, expanding each {@code message-batch} into its {@code payload} + * entries — the granularity most assertions want (e.g. locating {@code app-started} or {@code + * app-dependencies-loaded}). + */ + @SuppressWarnings("unchecked") + public List> getFlatMessages() { + List> flat = new ArrayList<>(); + for (Map message : getMessages()) { + Object payload = message.get("payload"); + if ("message-batch".equals(message.get("request_type")) && payload instanceof List) { + for (Object entry : (List) payload) { + if (entry instanceof Map) { + flat.add((Map) entry); + } + } + } else { + flat.add(message); + } + } + return flat; + } + + /** Waits (up to the default timeout) until at least {@code count} messages have been received. */ + public Telemetry waitForCount(int count) { + return waitForCount(count, DEFAULT_TIMEOUT_SECONDS); + } + + /** As {@link #waitForCount(int)}, but overriding the timeout for this call. */ + public Telemetry waitForCount(int count, double timeoutSeconds) { + new PollingConditions(timeoutSeconds) + .eventually( + () -> { + int actual = getMessages().size(); + if (actual < count) { + throw new AssertionError( + "Expected at least " + count + " telemetry message(s) but got " + actual); + } + }); + return this; + } + + /** Waits (default timeout) until a flattened telemetry event matches {@code predicate}. */ + public Telemetry waitForFlat(Predicate> predicate) { + return waitForFlat(predicate, DEFAULT_TIMEOUT_SECONDS); + } + + /** As {@link #waitForFlat(Predicate)}, but overriding the timeout for this call. */ + public Telemetry waitForFlat(Predicate> predicate, double timeoutSeconds) { + new PollingConditions(timeoutSeconds) + .eventually( + () -> { + if (getFlatMessages().stream().noneMatch(predicate)) { + throw new AssertionError("No telemetry event matched; received: " + getMessages()); + } + }); + return this; + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TelemetryDecoder.java b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TelemetryDecoder.java new file mode 100644 index 00000000000..c2638a4047e --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TelemetryDecoder.java @@ -0,0 +1,50 @@ +package datadog.smoketest.backend; + +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonDataException; +import com.squareup.moshi.Moshi; +import com.squareup.moshi.Types; +import java.io.IOException; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Parses app-telemetry JSON into maps: a single intake body (what the mock backend collects + * per-request) or the test agent's {@code /test/session/apmtelemetry} array. Moshi decodes JSON + * numbers as {@code Double}, which is fine for the presence/string assertions telemetry tests do. + */ +final class TelemetryDecoder { + private static final Type MESSAGE = + Types.newParameterizedType(Map.class, String.class, Object.class); + private static final Moshi MOSHI = new Moshi.Builder().build(); + private static final JsonAdapter> MESSAGE_ADAPTER = MOSHI.adapter(MESSAGE); + private static final JsonAdapter>> MESSAGE_LIST_ADAPTER = + MOSHI.adapter(Types.newParameterizedType(List.class, MESSAGE)); + + private TelemetryDecoder() {} + + /** Decodes one telemetry intake body (a JSON object) into a message map. */ + static Map decodeMessage(byte[] json) { + try { + Map message = + MESSAGE_ADAPTER.fromJson(new String(json, StandardCharsets.UTF_8)); + return message == null ? Collections.emptyMap() : message; + } catch (IOException | JsonDataException e) { + throw new IllegalStateException("Failed to parse telemetry message", e); + } + } + + /** Decodes the test agent's {@code /test/apmtelemetry} response (a JSON array of messages). */ + static List> decodeMessages(String json) { + try { + List> messages = MESSAGE_LIST_ADAPTER.fromJson(json); + return messages == null ? Collections.emptyList() : messages; + } catch (IOException | JsonDataException e) { + throw new IllegalStateException( + "Failed to parse /test/session/apmtelemetry response: " + json, e); + } + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TestAgentBackend.java b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TestAgentBackend.java new file mode 100644 index 00000000000..78e947c1a1c --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TestAgentBackend.java @@ -0,0 +1,323 @@ +package datadog.smoketest.backend; + +import datadog.trace.test.agent.decoder.DecodedTrace; +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.DockerImageName; + +/** + * {@link TraceBackend} backed by a + * dd-apm-test-agent — either a Testcontainers-managed container ({@link Builder#build() + * .container()}, the local-dev default) or an already-running external agent ({@link + * Builder#external(String, int)}, the CI sidecar). It reads received traces from the agent's {@code + * /test/session/traces} JSON endpoint and decodes them into the shared {@link DecodedTrace} model + * via {@link TestAgentTraceDecoder} (S1b), so a test body written against the common {@link Traces} + * surface runs unchanged against this backend or the {@link MockAgentBackend} (Q2). + * + *

Per-test isolation (Q4a, Option B). A shared external agent serves every test + * in a job, so traces are scoped by an {@code X-Datadog-Test-Session-Token}: the backend owns a + * token (see {@link #sessionToken()}), the launched app emits it via {@code + * dd.test.agent.session.token} (S3a tracer change, wired by the S4 app launcher), {@link #clear()} + * opens a fresh session with it, and {@link #traces()} reads only that session's traces. + * + *

Testcontainers is a {@code compileOnly} dependency of the smoke base, so this class only loads + * when a test actually selects a test-agent backend (mock-only tests stay Testcontainers-free). + */ +public final class TestAgentBackend implements TraceBackend { + /** The dd-apm-test-agent trace port inside the container. */ + private static final int AGENT_PORT = 8126; + + /** + * Default image — the CI mirror used by {@code TracerConnectionReliabilityTest}. Override with + * {@link Builder#image(String)} or the {@code datadog.smoketest.testagent.image} system property + * (e.g. the public {@code ghcr.io/datadog/dd-apm-test-agent/ddapm-test-agent} for local runs + * without registry access). + */ + private static final String DEFAULT_IMAGE = + System.getProperty( + "datadog.smoketest.testagent.image", + "registry.ddbuild.io/images/mirror/dd-apm-test-agent/ddapm-test-agent:v1.44.0"); + + /** + * Trace-invariant checks enabled by default, mirroring {@code TracerConnectionReliabilityTest}. + */ + private static final List DEFAULT_ENABLED_CHECKS = + Arrays.asList("trace_count_header", "meta_tracer_version_header", "trace_content_length"); + + private final String image; + private final String externalHost; // null => Testcontainers-managed container + private final int externalPort; + private final List enabledChecks; + private final boolean shared; + private final boolean retainAcrossTests; + private final String sessionToken; + + private final OkHttpClient client = new OkHttpClient(); + private volatile GenericContainer container; + private volatile HttpUrl baseUrl; + + private TestAgentBackend(Builder builder) { + this.image = builder.image; + this.externalHost = builder.externalHost; + this.externalPort = builder.externalPort; + this.enabledChecks = new ArrayList<>(builder.enabledChecks); + this.shared = builder.shared; + this.retainAcrossTests = builder.retainAcrossTests; + this.sessionToken = + builder.sessionToken != null ? builder.sessionToken : "smoke-" + UUID.randomUUID(); + } + + static Builder builder() { + return new Builder(); + } + + /** + * The session token this backend scopes its traces by. The launched app must emit it via {@code + * -Ddd.test.agent.session.token=} so its traces land in this backend's session. + */ + @Override + public String sessionToken() { + return this.sessionToken; + } + + /** Whether this backend is meant to be shared across multiple apps (consumed by S6). */ + @Override + public boolean isShared() { + return this.shared; + } + + @Override + public boolean clearsBetweenTests() { + return !this.retainAcrossTests; + } + + @Override + public void start() { + if (this.baseUrl != null) { + return; + } + if (this.externalHost != null) { + this.baseUrl = + new HttpUrl.Builder() + .scheme("http") + .host(this.externalHost) + .port(this.externalPort) + .build(); + } else { + GenericContainer started = new GenericContainer<>(DockerImageName.parse(this.image)); + started.withExposedPorts(AGENT_PORT); + started.withEnv("ENABLED_CHECKS", String.join(",", this.enabledChecks)); + started.setWaitStrategy(Wait.forHttp("/test/traces")); + started.start(); + this.container = started; + this.baseUrl = + new HttpUrl.Builder() + .scheme("http") + .host(started.getHost()) + .port(started.getMappedPort(AGENT_PORT)) + .build(); + } + // Open a fresh session so the very first test method starts clean. + clear(); + } + + @Override + public int port() { + return requireStarted().port(); + } + + @Override + public URI url() { + HttpUrl url = requireStarted(); + // Clean base URI without HttpUrl's trailing "/", so callers can append their own path. + return URI.create(url.scheme() + "://" + url.host() + ":" + url.port()); + } + + @Override + public Traces traces() { + return new Traces(this::fetchTraces); + } + + @Override + public Telemetry telemetry() { + return new Telemetry(this::fetchTelemetry); + } + + @Override + public void clear() { + // GET /test/session/start begins (and clears) a session identified by the token. The + // dd-apm-test-agent session endpoints are GET (verified against v1.44.0: POST returns 405). + HttpUrl url = + requireStarted() + .newBuilder() + .addPathSegments("test/session/start") + .addQueryParameter("test_session_token", this.sessionToken) + .build(); + Request request = new Request.Builder().url(url).get().build(); + execute(request, "start test-agent session"); + } + + @Override + public void close() { + GenericContainer running = this.container; + try { + // Container backends auto-validate their trace-invariant checks at teardown (Q5). External CI + // agents are validated by the job-final .gitlab/check_test_agent_results.sh instead, so we + // don't check them here. Run before stopping so the agent is still reachable. + if (running != null) { + assertNoInvariantFailures(); + } + } finally { + if (running != null) { + this.container = null; + running.stop(); + } + this.baseUrl = null; + } + } + + /** + * Fails (with an {@link AssertionError}) if the test agent recorded any trace-invariant check + * failure ({@code ENABLED_CHECKS}) for this backend's session. Auto-invoked at container teardown + * (Q5); may also be called explicitly against an external agent mid-test. + */ + public void assertNoInvariantFailures() { + HttpUrl url = + requireStarted() + .newBuilder() + .addPathSegments("test/trace_check/failures") + .addQueryParameter("test_session_token", this.sessionToken) + .build(); + Request request = new Request.Builder().url(url).get().build(); + try (Response response = this.client.newCall(request).execute()) { + int code = response.code(); + // 200 => all checks passed; 404 => a real agent is running (no checks). Anything else is a + // recorded failure, whose body describes the failing check(s) (see check_test_agent_results). + if (code == 200 || code == 404) { + return; + } + String body = response.body() == null ? "" : response.body().string(); + throw new AssertionError( + "Test-agent trace-invariant checks failed (HTTP " + code + "):\n" + body); + } catch (IOException e) { + throw new IllegalStateException("Failed to query trace-invariant checks at " + url, e); + } + } + + private List fetchTraces() { + HttpUrl url = + requireStarted() + .newBuilder() + .addPathSegments("test/session/traces") + .addQueryParameter("test_session_token", this.sessionToken) + .build(); + Request request = new Request.Builder().url(url).get().build(); + return TestAgentTraceDecoder.decode(execute(request, "read test-agent session traces")); + } + + private List> fetchTelemetry() { + // Session-scoped, like traces: the tracer's telemetry client now emits the + // X-Datadog-Test-Session-Token header (TelemetryClient), so a shared external agent attributes + // telemetry to this test's session and it stays isolated per test. + HttpUrl url = + requireStarted() + .newBuilder() + .addPathSegments("test/session/apmtelemetry") + .addQueryParameter("test_session_token", this.sessionToken) + .build(); + Request request = new Request.Builder().url(url).get().build(); + return TelemetryDecoder.decodeMessages(execute(request, "read test-agent session telemetry")); + } + + private String execute(Request request, String action) { + try (Response response = this.client.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IllegalStateException( + "Failed to " + action + ": HTTP " + response.code() + " from " + request.url()); + } + return response.body() == null ? "" : response.body().string(); + } catch (IOException e) { + throw new IllegalStateException("Failed to " + action + " at " + request.url(), e); + } + } + + private HttpUrl requireStarted() { + HttpUrl url = this.baseUrl; + if (url == null) { + throw new IllegalStateException("TestAgentBackend not started — call start() first"); + } + return url; + } + + /** Fluent builder; obtain via {@code TraceBackend.testAgentBuilder()}. */ + public static final class Builder { + private String image = DEFAULT_IMAGE; + private String externalHost; + private int externalPort = AGENT_PORT; + private final List enabledChecks = new ArrayList<>(DEFAULT_ENABLED_CHECKS); + private boolean shared; + private boolean retainAcrossTests; + private String sessionToken; + + private Builder() {} + + /** + * Uses a Testcontainers-managed container of the given image (default {@link #DEFAULT_IMAGE}). + */ + public Builder image(String image) { + this.image = image; + return this; + } + + /** Overrides the enabled trace-invariant checks ({@code ENABLED_CHECKS}). */ + public Builder enabledChecks(String... checks) { + this.enabledChecks.clear(); + this.enabledChecks.addAll(Arrays.asList(checks)); + return this; + } + + /** Talks to an already-running external agent (e.g. the CI sidecar) instead of a container. */ + public Builder external(String host, int port) { + this.externalHost = host; + this.externalPort = port; + return this; + } + + /** Marks this backend as shared across multiple apps (multi-app wiring lands in S6). */ + public Builder shared() { + this.shared = true; + return this; + } + + /** + * Keeps received traces across test methods instead of clearing them before each (see {@link + * TraceBackend#clearsBetweenTests()}). Use when assertions cover app-startup traces, which a + * per-method clear would discard. + */ + public Builder retainAcrossTests() { + this.retainAcrossTests = true; + return this; + } + + /** Overrides the auto-generated session token (mainly for deterministic tests). */ + public Builder sessionToken(String token) { + this.sessionToken = token; + return this; + } + + public TestAgentBackend build() { + return new TestAgentBackend(this); + } + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TestAgentTraceDecoder.java b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TestAgentTraceDecoder.java new file mode 100644 index 00000000000..b3b3c2ca971 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TestAgentTraceDecoder.java @@ -0,0 +1,208 @@ +package datadog.smoketest.backend; + +import com.squareup.moshi.Json; +import com.squareup.moshi.JsonAdapter; +import com.squareup.moshi.JsonDataException; +import com.squareup.moshi.Moshi; +import com.squareup.moshi.Types; +import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedTrace; +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Parses the dd-apm-test-agent {@code /test/traces} JSON response into the same {@link + * DecodedTrace} / {@link DecodedSpan} model the mock backend produces via the msgpack {@code + * Decoder}. This is what lets the thin smoke matcher ({@code datadog.smoketest.trace}) work + * uniformly across both backends (Q1). + * + *

The endpoint returns a JSON array of traces, each a JSON array of spans in the standard v0.4 + * shape. + */ +public final class TestAgentTraceDecoder { + private static final Type LIST_OF_TRACES = + Types.newParameterizedType(List.class, Types.newParameterizedType(List.class, RawSpan.class)); + private static final JsonAdapter>> ADAPTER = + new Moshi.Builder().build().adapter(LIST_OF_TRACES); + + private TestAgentTraceDecoder() {} + + /** Decodes a {@code /test/traces} JSON body into the shared decoded-trace model. */ + public static List decode(String json) { + List> rawTraces; + try { + // IOException covers malformed JSON (JsonEncodingException); JsonDataException (unchecked) + // covers a well-formed body of the wrong shape (e.g. an object where a trace array is + // expected). Wrap both so callers always get the offending body for diagnosis. + rawTraces = ADAPTER.fromJson(json); + } catch (IOException | JsonDataException e) { + throw new IllegalStateException("Failed to parse /test/traces response: " + json, e); + } + List traces = new ArrayList<>(); + if (rawTraces != null) { + for (List spans : rawTraces) { + List decodedSpans = + spans == null ? Collections.emptyList() : new ArrayList<>(spans); + traces.add(new RawTrace(decodedSpans)); + } + } + return traces; + } + + private static final class RawTrace implements DecodedTrace { + private final List spans; + + RawTrace(List spans) { + this.spans = Collections.unmodifiableList(spans); + } + + @Override + public List getSpans() { + return this.spans; + } + + @Override + public String toString() { + return "RawTrace[" + this.spans.toString(); + } + } + + // Field names follow the standard v0.4 trace shape and are verified end-to-end against a live + // ddapm-test-agent v1.44.0 /test/session/traces response (S3 TestAgentBackendContainerTest): + // service/name/resource/type, trace_id/span_id/parent_id, start/duration/error, meta, metrics. + // TODO the exact meta_struct wire shape over JSON is not asserted yet (no meta_struct-bearing + // span + // in the fixture) — confirm when a smoke test needs meta_struct. + static final class RawSpan implements DecodedSpan { + String service; + String name; + String resource; + String type; + + @Json(name = "trace_id") + long traceId; + + @Json(name = "span_id") + long spanId; + + @Json(name = "parent_id") + long parentId; + + long start; + long duration; + int error; + Map meta; + + @Json(name = "meta_struct") + Map metaStruct; + + Map metrics; + + @Override + public String getService() { + return this.service; + } + + @Override + public String getName() { + return this.name; + } + + @Override + public String getResource() { + return this.resource; + } + + @Override + public long getTraceId() { + return this.traceId; + } + + @Override + public long getSpanId() { + return this.spanId; + } + + @Override + public long getParentId() { + return this.parentId; + } + + @Override + public long getStart() { + return this.start; + } + + @Override + public long getDuration() { + return this.duration; + } + + @Override + public int getError() { + return this.error; + } + + @Override + public Map getMeta() { + return this.meta; + } + + @Override + public Map getMetaStruct() { + return this.metaStruct; + } + + @Override + public Map getMetrics() { + // The decoder model exposes Number; Moshi deserializes JSON numbers as Double. + return this.metrics == null ? null : new HashMap<>(this.metrics); + } + + @Override + public String getType() { + return this.type; + } + + @Override + public String toString() { + return "RawSpan{" + + "service='" + + this.service + + '\'' + + ", name='" + + this.name + + '\'' + + ", resource='" + + this.resource + + '\'' + + ", type='" + + this.type + + '\'' + + ", traceId=" + + this.traceId + + ", spanId=" + + this.spanId + + ", parentId=" + + this.parentId + + ", start=" + + this.start + + ", duration=" + + this.duration + + ", error=" + + this.error + + ", meta=" + + this.meta + + ", metaStruct=" + + this.metaStruct + + ", metrics=" + + this.metrics + + '}'; + } + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TraceBackend.java b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TraceBackend.java new file mode 100644 index 00000000000..cf01d49373e --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TraceBackend.java @@ -0,0 +1,125 @@ +package datadog.smoketest.backend; + +import datadog.trace.test.agent.decoder.DecodedTrace; +import java.net.URI; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.opentest4j.TestAbortedException; +import org.testcontainers.DockerClientFactory; + +/** + * A pluggable trace backend a smoke-test app sends its traces to. Two implementations are planned: + * the in-process {@link #mockAgent() mock agent} (this step, S2) and a Dockerized/external + * dd-apm-test-agent (S3). Both decode received traces into the shared {@link DecodedTrace} model + * (via the msgpack {@code Decoder}, or {@link TestAgentTraceDecoder} for the test agent), so a test + * body written against the common {@link Traces} surface runs unchanged on either backend (Q2). + * + *

The lifecycle mirrors the JUnit extension that will own the backend (S4/S6): {@link #start()} + * once per test class, {@link #clear()} between methods, {@link #close()} at teardown. + */ +public interface TraceBackend + extends AutoCloseable, BeforeAllCallback, BeforeEachCallback, AfterAllCallback { + /** Starts the backend and binds it to a port. Idempotent. */ + void start(); + + /** The agent port the app should send traces to (e.g. {@code -Ddd.trace.agent.port}). */ + int port(); + + /** The base URL of the backend. */ + URI url(); + + /** Query/assert facade over the traces this backend has received. */ + Traces traces(); + + /** Query facade over the app-telemetry messages this backend has received (S9). */ + Telemetry telemetry(); + + /** Discards all traces received so far — call between test methods to isolate them. */ + void clear(); + + @Override + void close(); + + /** + * The session token the launched app must emit (via {@code dd.test.agent.session.token}) for its + * traces to be attributed to this backend, or {@code null} if the backend does not scope by + * session (e.g. the in-process mock, which owns its own server). Overridden by the test agent. + */ + default String sessionToken() { + return null; + } + + /** + * Whether this backend manages its own lifecycle as a separate {@code @RegisterExtension} shared + * across apps (S6). When {@code false} (default), the owning {@code AbstractSmokeApp} + * starts/stops it. + */ + default boolean isShared() { + return false; + } + + /** + * Whether {@link #beforeEach} clears received traces so each test method sees only its own (the + * default). Return {@code false} to accumulate across methods — needed when the + * assertions cover traces emitted at app startup (before the first test method), which a + * per-method clear would discard. Overridden by the test agent's {@code retainAcrossTests()}. + */ + default boolean clearsBetweenTests() { + return true; + } + + // JUnit lifecycle: a backend declared as its own `@RegisterExtension` field (shared across apps, + // S6/Q8) drives its own start/clear/close. An inline backend passed to `backend(...)` on an app + // builder is not registered as an extension, so the app drives it instead. start() is idempotent, + // so a shared backend that an app also (defensively) starts is unaffected. + + @Override + default void beforeAll(ExtensionContext context) { + start(); + } + + @Override + default void beforeEach(ExtensionContext context) { + if (clearsBetweenTests()) { + clear(); + } + } + + @Override + default void afterAll(ExtensionContext context) { + close(); + } + + /** Creates an in-process mock-agent backend wrapping the testing {@code JavaTestHttpServer}. */ + static MockAgentBackend mockAgent() { + return new MockAgentBackend(); + } + + /** Fluent builder for a {@link TestAgentBackend} (dd-apm-test-agent container or external). */ + static TestAgentBackend.Builder testAgentBuilder() { + return TestAgentBackend.builder(); + } + + /** + * Resolves the environment's default test-agent backend (Q4): the external CI sidecar when {@code + * CI_AGENT_HOST} is set, else a Testcontainers-managed container when Docker is available, else a + * loud skip (a {@link TestAbortedException} marks the test aborted rather than failed, so a dev + * not running smoke tests isn't blocked). The mock backend is never an automatic fallback — + * select it explicitly with {@link #mockAgent()}. The reusable conditional-execution gate over + * this policy is {@link EnabledIfDockerAvailable} (S7). + */ + static TraceBackend testAgent() { + String ciAgentHost = System.getenv("CI_AGENT_HOST"); + if (ciAgentHost != null && !ciAgentHost.isEmpty()) { + return testAgentBuilder().external(ciAgentHost, 8126).build(); + } + if (DockerClientFactory.instance().isDockerAvailable()) { + return testAgentBuilder().build(); + } + throw new TestAbortedException( + "No test-agent backend available: set CI_AGENT_HOST for an external agent, or start Docker " + + "for a container. Use TraceBackend.mockAgent() to opt into the in-process mock."); + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/backend/Traces.java b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/Traces.java new file mode 100644 index 00000000000..b2b1f9007ca --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/Traces.java @@ -0,0 +1,96 @@ +package datadog.smoketest.backend; + +import static java.util.function.UnaryOperator.identity; + +import datadog.smoketest.trace.SmokeTraceAssertions; +import datadog.smoketest.trace.TraceMatcher; +import datadog.trace.test.agent.decoder.DecodedTrace; +import datadog.trace.test.util.PollingConditions; +import java.util.List; +import java.util.function.Supplier; +import java.util.function.UnaryOperator; + +/** + * Query facade over the traces a {@link TraceBackend} has received. It is backend-agnostic: it + * reads a live snapshot of decoded traces, so the same assertions run against either backend (Q2). + * + *

S2 provides trace retrieval and count-polling. The fluent matcher integration ({@code + * assertTraces(trace(span()...))} over the smoke matcher in {@code datadog.smoketest.trace}) and + * the test-agent trace-invariant checks land in S5. + */ +public final class Traces { + /** Default time to wait for traces to arrive from a separately-launched app before giving up. */ + private static final double DEFAULT_TIMEOUT_SECONDS = 10; + + private final Supplier> source; + + Traces(Supplier> source) { + this.source = source; + } + + /** A snapshot of the traces received so far. */ + public List getTraces() { + return this.source.get(); + } + + /** + * Waits (up to the default timeout) until at least {@code count} traces have been + * received, then returns for chaining. Traces arrive asynchronously from the app, so callers wait + * for the expected count before asserting structure. + */ + public Traces waitForTraceCount(int count) { + return waitForTraceCount(count, DEFAULT_TIMEOUT_SECONDS); + } + + /** As {@link #waitForTraceCount(int)}, but overriding the timeout for this call. */ + public Traces waitForTraceCount(int count, double timeoutSeconds) { + new PollingConditions(timeoutSeconds) + .eventually( + () -> { + int actual = getTraces().size(); + if (actual < count) { + throw new AssertionError( + "Expected at least " + count + " trace(s) but got " + actual); + } + }); + return this; + } + + /** + * Polls (up to the default timeout) until the received traces satisfy the thin smoke matcher — + * one {@link TraceMatcher} per expected trace (matched positionally, count-exact). Polling + * absorbs the async arrival of traces from a separately-launched app. Sugar over {@link + * SmokeTraceAssertions#assertTraces}: + * + *

{@code
+   * app.traces().assertTraces(
+   *     trace(span().operationName("servlet.request").resourceName("GET /greeting")));
+   * }
+ */ + public void assertTraces(TraceMatcher... matchers) { + assertTraces(identity(), matchers); + } + + /** + * As {@link #assertTraces(TraceMatcher...)} with options (e.g. {@code + * SmokeTraceAssertions.UNORDERED} / {@code IGNORE_ADDITIONAL_TRACES} / {@code + * SORT_BY_START_TIME}). + */ + public void assertTraces( + UnaryOperator options, TraceMatcher... matchers) { + assertTraces(DEFAULT_TIMEOUT_SECONDS, options, matchers); + } + + /** As {@link #assertTraces(UnaryOperator, TraceMatcher...)}, overriding the timeout. */ + public void assertTraces( + double timeoutSeconds, + UnaryOperator options, + TraceMatcher... matchers) { + new PollingConditions(timeoutSeconds) + .eventually(() -> SmokeTraceAssertions.assertTraces(getTraces(), options, matchers)); + } + + // Trace-invariant checks (ENABLED_CHECKS) are a test-agent-specific concern, validated by that + // backend itself (TestAgentBackend#assertNoInvariantFailures, auto-run at container teardown per + // Q5) rather than on this common facade, which stays portable across both backends (Q2). +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SmokeTraceAssertions.java b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SmokeTraceAssertions.java new file mode 100644 index 00000000000..56ec79b4855 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SmokeTraceAssertions.java @@ -0,0 +1,165 @@ +package datadog.smoketest.trace; + +import static java.lang.Long.MAX_VALUE; +import static java.lang.Math.min; +import static java.util.function.UnaryOperator.identity; +import static org.junit.jupiter.api.AssertionFailureBuilder.assertionFailure; + +import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedTrace; +import java.util.ArrayList; +import java.util.BitSet; +import java.util.Comparator; +import java.util.List; +import java.util.function.UnaryOperator; + +/** + * This class is a helper class to verify trace structure. + * + *

To check for trace structure, use the static factory methods: {@link #assertTraces(List, + * TraceMatcher...)} with the expected {@link TraceMatcher}s (one per trace), or {@link + * #assertTraces(List, UnaryOperator, TraceMatcher...)} to configure the checks with a {@link + * Options} object. + * + *

The following predefined configurations: + * + *

    + *
  • {@link #IGNORE_ADDITIONAL_TRACES} ignores additional traces if there are more than + * expected, + *
  • {@link #SORT_BY_START_TIME} sorts traces by their start time, + *
  • {@link #UNORDERED} allows matchers to match any distinct trace rather than the one at its + * position. + *
+ */ +public final class SmokeTraceAssertions { + /* + * Trace comparators. + */ + /** Trace comparator to sort by start time. */ + public static final Comparator TRACE_START_TIME_COMPARATOR = + Comparator.comparingLong(SmokeTraceAssertions::earliestStart); + + /* + * Trace assertion options. + */ + /** Ignores additional traces. If there are more traces than expected, do not fail. */ + public static final UnaryOperator IGNORE_ADDITIONAL_TRACES = + Options::ignoreAdditionalTraces; + + /** Allows matchers to match any distinct trace rather than the one at its position. */ + public static final UnaryOperator UNORDERED = Options::unorder; + + /** Sorts traces by start time. */ + public static final UnaryOperator SORT_BY_START_TIME = + options -> options.sort(TRACE_START_TIME_COMPARATOR); + + private SmokeTraceAssertions() {} + + /** + * Checks the structure of a trace collection. + * + * @param traces The trace collection to check. + * @param matchers The matchers to verify the trace collection, one matcher by expected trace. + */ + public static void assertTraces(List traces, TraceMatcher... matchers) { + assertTraces(traces, identity(), matchers); + } + + /** + * Checks the structure of a trace collection. + * + * @param traces The trace collection to check. + * @param options The {@link Options} to configure the checks. + * @param matchers The matchers to verify the trace collection, one matcher by expected trace. + */ + public static void assertTraces( + List traces, UnaryOperator options, TraceMatcher... matchers) { + Options opts = options.apply(new Options()); + // Check trace count first + int traceCount = traces.size(); + if (opts.ignoreAdditionalTraces) { + if (traceCount < matchers.length) { + assertionFailure() + .message("Not enough of traces") + .expected(matchers.length) + .actual(traceCount) + .buildAndThrow(); + } + } else { + if (traceCount != matchers.length) { + assertionFailure() + .message("Invalid number of traces") + .expected(matchers.length) + .actual(traceCount) + .buildAndThrow(); + } + } + // Apply sorter + if (opts.sorter != null) { + traces = new ArrayList<>(traces); + traces.sort(opts.sorter); + } + // Assert traces + boolean strictPositional = !opts.unordered && !opts.ignoreAdditionalTraces; + BitSet skippedTraces = new BitSet(traceCount); + for (int matcherIndex = 0; matcherIndex < matchers.length; matcherIndex++) { + TraceMatcher matcher = matchers[matcherIndex]; + if (strictPositional) { + matcher.assertTrace(traces.get(matcherIndex).getSpans(), matcherIndex); + continue; + } + boolean matched = false; + for (int traceIndex = skippedTraces.nextClearBit(0); traceIndex < traceCount; traceIndex++) { + if (skippedTraces.get(traceIndex)) { + continue; + } + try { + matcher.assertTrace(traces.get(traceIndex).getSpans(), matcherIndex); + matched = true; + if (opts.unordered) { + skippedTraces.set(traceIndex); + } else { + skippedTraces.set(0, traceIndex); + } + break; + } catch (AssertionError ignored) { + // Swallow assertion errors, keep looking for a match + } + } + if (!matched) { + assertionFailure().message("No trace matches matcher # " + matcherIndex).buildAndThrow(); + } + } + } + + private static long earliestStart(DecodedTrace trace) { + long start = MAX_VALUE; + for (DecodedSpan span : trace.getSpans()) { + start = min(start, span.getStart()); + } + return start == MAX_VALUE ? 0L : start; + } + + public static final class Options { + boolean ignoreAdditionalTraces = false; + boolean unordered = false; + Comparator sorter = null; + + public Options ignoreAdditionalTraces() { + this.ignoreAdditionalTraces = true; + return this; + } + + public Options unorder() { + this.unordered = true; + this.sorter = null; + return this; + } + + public Options sort(Comparator sorter) { + this.unordered = false; + this.sorter = sorter; + return this; + } + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanMatcher.java b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanMatcher.java new file mode 100644 index 00000000000..5095b579dab --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanMatcher.java @@ -0,0 +1,191 @@ +package datadog.smoketest.trace; + +import static datadog.trace.test.junit.utils.assertions.Matchers.any; +import static datadog.trace.test.junit.utils.assertions.Matchers.assertValue; +import static datadog.trace.test.junit.utils.assertions.Matchers.is; +import static datadog.trace.test.junit.utils.assertions.Matchers.isFalse; +import static datadog.trace.test.junit.utils.assertions.Matchers.isTrue; +import static datadog.trace.test.junit.utils.assertions.Matchers.matches; +import static datadog.trace.test.junit.utils.assertions.Matchers.validates; + +import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.junit.utils.assertions.Matcher; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * Thin span matcher for smoke tests, operating on the serialized {@link DecodedSpan} model + * produced by both smoke backends (the in-process mock agent and the dd-apm-test-agent). + * + *

This is deliberately separate from the in-process instrumentation DSL ({@code + * datadog.trace.agent.test.assertions}, which matches on {@code DDSpan}); the two share only the + * generic value matchers in {@code datadog.trace.junit.utils.assertions}. Only what a span carries + * once serialized is matchable here: name/service/resource/type, error status, {@code meta} (string + * tags), {@code metrics} (numeric tags), and parent linkage. + * + *

Parent linkage can be expressed by {@link #root()}, {@link #childOf(long) explicit id}, or — + * for structural assertions within a sorted trace — by position with {@link #childOfIndex(int)} / + * {@link #childOfPrevious()} (mirroring the instrumentation DSL). Use the {@link #span()} factory + * as a fluent builder; unset constraints are not checked. + */ +public final class SpanMatcher { + private Matcher serviceMatcher; + private Matcher operationNameMatcher; + private Matcher resourceNameMatcher; + private Matcher typeMatcher; + private Matcher errorMatcher; + private Long parentId; // explicit expected parent id (childOf/root); null => not checked by id + private int parentSpanIndex = -1; // >= 0 => parent is the span at this index in the sorted trace + private boolean childOfPrevious; // parent is the preceding span in the sorted trace + private final Map> metaMatchers = new HashMap<>(); + private final Map> metricMatchers = new HashMap<>(); + + private SpanMatcher() {} + + /** Creates a new span matcher; configure it with the fluent methods below. */ + public static SpanMatcher span() { + return new SpanMatcher(); + } + + public SpanMatcher service(String service) { + this.serviceMatcher = is(service); + return this; + } + + public SpanMatcher operationName(String operationName) { + this.operationNameMatcher = is(operationName); + return this; + } + + public SpanMatcher operationName(Pattern pattern) { + this.operationNameMatcher = matches(pattern); + return this; + } + + public SpanMatcher resourceName(String resourceName) { + this.resourceNameMatcher = is(resourceName); + return this; + } + + public SpanMatcher resourceName(Pattern pattern) { + this.resourceNameMatcher = matches(pattern); + return this; + } + + public SpanMatcher resourceName(Predicate validator) { + this.resourceNameMatcher = validates(validator); + return this; + } + + public SpanMatcher type(String type) { + this.typeMatcher = is(type); + return this; + } + + /** Matches the span error status. */ + public SpanMatcher error(boolean errored) { + this.errorMatcher = errored ? isTrue() : isFalse(); + return this; + } + + /** Matches a root span (no parent). */ + public SpanMatcher root() { + this.parentId = 0L; + this.parentSpanIndex = -1; + this.childOfPrevious = false; + return this; + } + + /** Matches a span whose parent is the given span id. */ + public SpanMatcher childOf(long parentSpanId) { + this.parentId = parentSpanId; + this.parentSpanIndex = -1; + this.childOfPrevious = false; + return this; + } + + /** Matches a span whose parent is the span at {@code parentSpanIndex} in the (sorted) trace. */ + public SpanMatcher childOfIndex(int parentSpanIndex) { + this.parentSpanIndex = parentSpanIndex; + this.parentId = null; + this.childOfPrevious = false; + return this; + } + + /** Matches a span whose parent is the immediately preceding span in the (sorted) trace. */ + public SpanMatcher childOfPrevious() { + this.childOfPrevious = true; + this.parentId = null; + this.parentSpanIndex = -1; + return this; + } + + /** Matches a {@code meta} (string) tag against the given matcher. */ + public SpanMatcher tag(String name, Matcher matcher) { + this.metaMatchers.put(name, matcher); + return this; + } + + /** Matches a {@code meta} (string) tag against the given value. */ + public SpanMatcher tag(String name, String value) { + this.metaMatchers.put(name, is(value)); + return this; + } + + /** Matches a {@code metrics} (numeric) tag against the given matcher. */ + public SpanMatcher metric(String name, Matcher matcher) { + this.metricMatchers.put(name, matcher); + return this; + } + + // TODO thin: exhaustive tag coverage (fail on unexpected meta/metrics) and span-links. + + /** Positional (count-exact) assertion: the span at {@code spanIndex} matches this matcher. */ + void assertSpan(List trace, int spanIndex) { + assertValue( + parentIdMatcher(trace, spanIndex), + trace.get(spanIndex).getParentId(), + "Unexpected parent id"); + assertFields(trace.get(spanIndex)); + } + + /** Asserts a span's own fields (service/operation/resource/type/error/tags), ignoring linkage. */ + @SuppressWarnings("unchecked") + private void assertFields(DecodedSpan span) { + assertValue(this.serviceMatcher, span.getService(), "Unexpected service name"); + assertValue(this.operationNameMatcher, span.getName(), "Unexpected operation name"); + assertValue(this.resourceNameMatcher, span.getResource(), "Unexpected resource name"); + assertValue(this.typeMatcher, span.getType(), "Unexpected span type"); + assertValue(this.errorMatcher, span.getError() != 0, "Unexpected error status"); + Map meta = span.getMeta(); + for (Map.Entry> entry : this.metaMatchers.entrySet()) { + Object value = meta == null ? null : meta.get(entry.getKey()); + assertValue( + (Matcher) entry.getValue(), + value, + "Unexpected meta tag '" + entry.getKey() + "'"); + } + Map metrics = span.getMetrics(); + for (Map.Entry> entry : this.metricMatchers.entrySet()) { + Object value = metrics == null ? null : metrics.get(entry.getKey()); + assertValue( + (Matcher) entry.getValue(), value, "Unexpected metric '" + entry.getKey() + "'"); + } + } + + private Matcher parentIdMatcher(List trace, int spanIndex) { + if (this.parentSpanIndex >= 0) { + return is(trace.get(parentSpanIndex).getSpanId()); + } + if (this.childOfPrevious) { + if (spanIndex == 0) { + throw new IllegalStateException("childOfPrevious() cannot be used on the first span"); + } + return is(trace.get(spanIndex - 1).getSpanId()); + } + return this.parentId == null ? any() : is(this.parentId); + } +} diff --git a/dd-smoke-tests/src/main/java/datadog/smoketest/trace/TraceMatcher.java b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/TraceMatcher.java new file mode 100644 index 00000000000..0ad9fb86bed --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/TraceMatcher.java @@ -0,0 +1,149 @@ +package datadog.smoketest.trace; + +import static java.util.Comparator.comparingLong; +import static java.util.stream.Collectors.toSet; + +import datadog.trace.test.agent.decoder.DecodedSpan; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.UnaryOperator; +import org.opentest4j.AssertionFailedError; + +/** + * This class is a helper class to verify a trace structure. + * + *

To get a {@link TraceMatcher}, use the static factory methods: {@link #trace(SpanMatcher...)} + * with the expected {@link SpanMatcher}s (one per expected span), or {@link #trace(UnaryOperator, + * SpanMatcher...)} to configure the checks with a {@link Options} object. + * + *

The following predefined configurations: + * + *

    + *
  • {@link #SORT_BY_START_TIME} sorts spans by start time, + *
  • {@link #SORT_BY_ANCESTRY} sorts spans by ancestry, root spans (or which parents are not + * present in the trace chunk) first, followed by their children by start time, depth-first + *
+ * + * @see SmokeTraceAssertions + * @see SpanMatcher + */ +public final class TraceMatcher { + /* + * Span comparators. + */ + /** Span comparator to sort by start time. */ + public static final Comparator START_TIME_COMPARATOR = + comparingLong(DecodedSpan::getStart).thenComparingLong(DecodedSpan::getSpanId); + + /* + * Span assertion options. + */ + /** Sorts spans by start time. */ + public static final UnaryOperator SORT_BY_START_TIME = + options -> options.sort(START_TIME_COMPARATOR); + + /** + * Sorts spans by ancestry, root spans (or which parents are absent from the trace chunk) first, + * followed by their children by start time, depth-first. + */ + public static final UnaryOperator SORT_BY_ANCESTRY = Options::sortByAncestry; + + private final Options options; + private final SpanMatcher[] matchers; + + private TraceMatcher(Options options, SpanMatcher[] spanMatchers) { + if (spanMatchers.length == 0) { + throw new IllegalArgumentException("No span matchers provided"); + } + this.options = options; + this.matchers = spanMatchers; + } + + /** + * Checks a trace structure. + * + * @param matchers The matchers to verify the trace structure. + */ + public static TraceMatcher trace(SpanMatcher... matchers) { + return new TraceMatcher(new Options(), matchers); + } + + /** + * Checks a trace structure. + * + * @param options The {@link TraceMatcher.Options} to configure the checks. + * @param matchers The matchers to verify the trace structure. + */ + public static TraceMatcher trace(UnaryOperator options, SpanMatcher... matchers) { + return new TraceMatcher(options.apply(new Options()), matchers); + } + + void assertTrace(List spans, int traceIndex) { + if (spans.size() != this.matchers.length) { + throw new AssertionFailedError( + "Invalid number of spans for trace " + traceIndex + " : " + spans, + this.matchers.length, + spans.size()); + } + if (this.options.sortByAncestry) { + spans = sortByAncestry(spans); + } else if (this.options.comparator != null) { + spans = new ArrayList<>(spans); + spans.sort(this.options.comparator); + } + for (int i = 0; i < this.matchers.length; i++) { + this.matchers[i].assertSpan(spans, i); + } + } + + private static List sortByAncestry(List spans) { + Set spanIds = spans.stream().map(DecodedSpan::getSpanId).collect(toSet()); + Map> spansByParentId = new HashMap<>(); + for (DecodedSpan span : spans) { + long parentId = span.getParentId(); + if (parentId != 0 && !spanIds.contains(parentId)) { + parentId = 0; + } + spansByParentId.computeIfAbsent(parentId, k -> new ArrayList<>()).add(span); + } + spansByParentId.forEach((k, v) -> v.sort(START_TIME_COMPARATOR)); + + List ordered = new ArrayList<>(spans.size()); + appendChildren(ordered, spansByParentId.get(0L), spansByParentId); + return ordered; + } + + private static void appendChildren( + List orderedSpan, + List children, + Map> spansByParentId) { + for (DecodedSpan child : children) { + orderedSpan.add(child); + List grandChildren = spansByParentId.get(child.getSpanId()); + if (grandChildren != null) { + appendChildren(orderedSpan, grandChildren, spansByParentId); + } + } + } + + public static final class Options { + private Comparator comparator = null; + private boolean sortByAncestry = false; + + public Options sort(Comparator comparator) { + this.comparator = comparator; + this.sortByAncestry = false; + return this; + } + + Options sortByAncestry() { + this.comparator = null; + this.sortByAncestry = true; + return this; + } + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/ErrorLogSmokeTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/ErrorLogSmokeTest.java new file mode 100644 index 00000000000..99f1b548c56 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/ErrorLogSmokeTest.java @@ -0,0 +1,39 @@ +package datadog.smoketest; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.smoketest.backend.TraceBackend; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Verifies {@link AbstractSmokeApp#assertNoErrorLogs()} against a real launched app: it reads the + * captured log and flags error lines. {@code skipErrorLogCheck()} disables the automatic teardown + * check so the deliberately-logged error doesn't fail the class — the assertion is exercised + * explicitly. + */ +class ErrorLogSmokeTest { + + @RegisterExtension + static final SmokeServerApp app = + SmokeServerApp.named("error-logger") + .mainClass("datadog.smoketest.TestServerApp") + .args("--server.port=${app.httpPort}") + .backend(TraceBackend.mockAgent()) + .noAgent() + .skipErrorLogCheck() + .build(); + + @Test + void detectsErrorLinesInTheLog() { + app.get("/hello"); + app.assertNoErrorLogs(); // no error logged yet + + app.get("/error"); // the app logs "ERROR simulated application error" + app.awaitLogLine(line -> line.contains("ERROR simulated")); // ensure it reached the log file + + AssertionError failure = assertThrows(AssertionError.class, app::assertNoErrorLogs); + assertTrue(failure.getMessage().contains("ERROR simulated"), failure.getMessage()); + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/SharedBackendMultiAppTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/SharedBackendMultiAppTest.java new file mode 100644 index 00000000000..dffa8ddd95c --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SharedBackendMultiAppTest.java @@ -0,0 +1,70 @@ +package datadog.smoketest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.smoketest.backend.MockAgentBackend; +import datadog.smoketest.backend.TraceBackend; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Multi-app composition with a shared backend (Q8): a single {@code @RegisterExtension} backend + * declared before two {@link SmokeServerApp} fields, each launching its own JVM. The shared backend + * is started/reset/closed by its own extension — the apps reference it (via field access) + * but don't own its lifecycle. Start-up is order-independent because {@code SmokeServerApp} starts + * the backend idempotently. + * + *

Runs without the agent, so it asserts the composition wiring (distinct app ports, one shared + * backend instance) rather than trace flow — cross-app trace assertions against a shared agent land + * in the S8 pilot. + */ +class SharedBackendMultiAppTest { + + @RegisterExtension static final MockAgentBackend agent = TraceBackend.mockAgent().shared(); + + @RegisterExtension + static final SmokeServerApp producer = + SmokeServerApp.named("producer") + .mainClass("datadog.smoketest.TestServerApp") + .args("--server.port=${app.httpPort}") + .backend(agent) + .noAgent() + .build(); + + @RegisterExtension + static final SmokeServerApp consumer = + SmokeServerApp.named("consumer") + .mainClass("datadog.smoketest.TestServerApp") + .args("--server.port=${app.httpPort}") + .backend(agent) + .noAgent() + .build(); + + @Test + void bothAppsRunOnDistinctPorts() { + assertNotEquals(producer.httpPort(), consumer.httpPort(), "each app gets its own port"); + assertEquals(200, producer.get("/"), "producer serves HTTP"); + assertEquals(200, consumer.get("/"), "consumer serves HTTP"); + } + + @Test + void appsShareTheSameBackend() { + assertTrue(agent.isShared(), "backend is marked shared"); + assertSame(agent, producer.backend(), "producer uses the shared backend"); + assertSame(agent, consumer.backend(), "consumer uses the shared backend"); + assertEquals( + producer.backend().port(), + consumer.backend().port(), + "one shared backend => one agent port for both apps"); + } + + @Test + void sharedBackendIsStartedByItsOwnExtension() { + assertNotNull(agent.url(), "shared backend was started"); + assertTrue(agent.traces().getTraces().isEmpty(), "no traces arrive without an agent"); + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppErrorLogFilterTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppErrorLogFilterTest.java new file mode 100644 index 00000000000..74db0886958 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppErrorLogFilterTest.java @@ -0,0 +1,34 @@ +package datadog.smoketest; + +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.function.Predicate; +import org.junit.jupiter.api.Test; + +/** Docker-free unit tests for {@link AbstractSmokeApp}'s default error-log predicate. */ +class SmokeAppErrorLogFilterTest { + + @Test + void flagsErrorAndAssertionLines() { + Predicate isError = AbstractSmokeApp.defaultErrorLogFilter(emptyList()); + + assertTrue(isError.test("2026-07-14 12:00:00 ERROR o.e.SomeClass - boom"), "ERROR line"); + assertTrue(isError.test("junit ASSERTION FAILED: expected X"), "assertion line"); + assertTrue( + isError.test("Failed to handle exception in instrumentation"), "instrumentation failure"); + assertFalse(isError.test("INFO all good"), "info line is not an error"); + assertFalse(isError.test("WARN heads up"), "warn line is not an error"); + } + + @Test + void respectsAllowlist() { + Predicate isError = + AbstractSmokeApp.defaultErrorLogFilter(singletonList("known flaky ERROR")); + + assertFalse(isError.test("this is a known flaky ERROR we tolerate"), "allowlisted line"); + assertTrue(isError.test("a real ERROR here"), "non-allowlisted error still flagged"); + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeServerAppTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeServerAppTest.java new file mode 100644 index 00000000000..f042b453736 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeServerAppTest.java @@ -0,0 +1,67 @@ +package datadog.smoketest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.smoketest.backend.TraceBackend; +import datadog.smoketest.backend.Traces; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Exercises {@link SmokeServerApp}'s launch mechanics end-to-end against a trivial JVM app ({@link + * TestServerApp}): port allocation + {@code ${app.httpPort}} substitution, process launch, HTTP + * reachability, stdout capture, owned-backend lifecycle, and parameter injection. Runs without the + * agent (mechanics only); a real agent + instrumented app + trace assertions land in the S8 pilot. + */ +class SmokeServerAppTest { + + @RegisterExtension + static final SmokeServerApp app = + SmokeServerApp.named("test-server") + .mainClass("datadog.smoketest.TestServerApp") + .placeholder("marker", () -> "resolved-at-launch") + .args("--server.port=${app.httpPort}", "--marker=${marker}") + .backend(TraceBackend.mockAgent()) + .noAgent() + .build(); + + @Test + void respondsOnTheAllocatedPort() { + assertTrue(app.httpPort() > 0, "a port was allocated"); + // Reaching the app proves ${app.httpPort} was substituted into the launch args. + assertEquals(200, app.get("/hello"), "app serves HTTP on the substituted port"); + } + + @Test + void capturesApplicationLogOutput() { + app.get("/ping"); + assertTrue( + app.awaitLogLine(line -> line.contains("REQUEST GET /ping")), + "app stdout is captured during the test"); + } + + @Test + void substitutesCustomPlaceholderAtLaunch() { + app.get("/ping"); + // The app echoes its --marker launch arg; seeing the resolved value proves the custom ${marker} + // placeholder was substituted from its Supplier when the app launched. + assertTrue( + app.awaitLogLine(line -> line.contains("marker=resolved-at-launch")), + "custom placeholder was substituted into the launch args"); + } + + @Test + void ownsAndStartsItsBackend() { + assertNotNull(app.backend().url(), "the owned backend was started before the app"); + assertTrue(app.traces().getTraces().isEmpty(), "no traces arrive without an agent"); + } + + @Test + void injectsHandlesByType(SmokeServerApp injected, Traces traces) { + assertSame(app, injected, "SmokeServerApp resolved by type"); + assertNotNull(traces, "Traces resolved by type"); + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/TestServerApp.java b/dd-smoke-tests/src/test/java/datadog/smoketest/TestServerApp.java new file mode 100644 index 00000000000..5d58d280d09 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/TestServerApp.java @@ -0,0 +1,54 @@ +package datadog.smoketest; + +import com.sun.net.httpserver.HttpServer; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CountDownLatch; + +/** + * Minimal HTTP server launched by {@link SmokeServerAppTest} to exercise {@link SmokeServerApp}'s + * launch/log-capture/lifecycle mechanics without needing the agent or a full sample app. Reads + * {@code --server.port=} (substituted by {@code SmokeServerApp} from {@code + * ${app.httpPort}}), echoes each request to stdout so log capture can be asserted, and blocks until + * the process is destroyed. + */ +public final class TestServerApp { + private TestServerApp() {} + + public static void main(String[] args) throws Exception { + int port = 0; + String marker = ""; + for (String arg : args) { + if (arg.startsWith("--server.port=")) { + port = Integer.parseInt(arg.substring("--server.port=".length())); + } else if (arg.startsWith("--marker=")) { + // Echoed on each request so tests can assert launch-arg (placeholder) substitution. + marker = arg.substring("--marker=".length()); + } + } + final String markerSuffix = marker.isEmpty() ? "" : " marker=" + marker; + + HttpServer server = HttpServer.create(new InetSocketAddress("localhost", port), 0); + server.createContext( + "/", + exchange -> { + String path = exchange.getRequestURI().getPath(); + System.out.println("REQUEST " + exchange.getRequestMethod() + " " + path + markerSuffix); + if ("/error".equals(path)) { + // Emit an error line so tests can exercise the no-error-logs check. + System.out.println("ERROR simulated application error"); + } + byte[] body = "ok".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + }); + server.start(); + System.out.println("TestServerApp listening on " + port); + + // Block forever; SmokeServerApp destroys the process at teardown. + new CountDownLatch(1).await(); + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/backend/DockerAvailableConditionTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/DockerAvailableConditionTest.java new file mode 100644 index 00000000000..468e491ba48 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/DockerAvailableConditionTest.java @@ -0,0 +1,25 @@ +package datadog.smoketest.backend; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ConditionEvaluationResult; + +/** + * Verifies the {@link EnabledIfDockerAvailable} condition evaluates cleanly regardless of the + * Docker state — probing failures are swallowed and a reason is always attached. Whether it enables + * or disables is exercised end-to-end by {@link TestAgentBackendContainerTest} (which carries the + * annotation): it runs when Docker is present, and is skipped otherwise. + */ +class DockerAvailableConditionTest { + + @Test + void evaluatesToAResultWithAReason() { + ConditionEvaluationResult result = + new DockerAvailableCondition().evaluateExecutionCondition(null); + + assertNotNull(result, "condition returns a result without throwing"); + assertTrue(result.getReason().isPresent(), "both the enabled and disabled paths give a reason"); + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/backend/MockAgentBackendTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/MockAgentBackendTest.java new file mode 100644 index 00000000000..1eb7ed6576f --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/MockAgentBackendTest.java @@ -0,0 +1,224 @@ +package datadog.smoketest.backend; + +import static datadog.smoketest.trace.SpanMatcher.span; +import static datadog.smoketest.trace.TraceMatcher.trace; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedTrace; +import datadog.trace.test.agent.decoder.Decoder; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests the in-process {@link MockAgentBackend}: it must answer the tracer's {@code /info} probe, + * accept the trace payloads the tracer PUTs, and decode them into the shared {@link DecodedTrace} + * model. Drives the backend with a recorded v0.4 msgpack payload over real HTTP (okhttp), the same + * way a launched app's tracer would (S2). + * + *

Uses a per-class backend cleared per method, mirroring the intended smoke-test lifecycle (Q3: + * backend started once per class, reset between methods). + */ +class MockAgentBackendTest { + private static final MediaType MSGPACK = MediaType.parse("application/msgpack"); + private static final MediaType JSON = MediaType.parse("application/json"); + private static final OkHttpClient CLIENT = new OkHttpClient(); + + // Recorded /v0.4/traces payload: 1 trace, 2 spans (netty.request -> WebController.hello), + // service "smoke-test-java-app". Same fixture the decoder module's DecoderTest uses. + private static byte[] v04Payload; + + private static MockAgentBackend backend; + + @BeforeAll + static void setUp() throws IOException { + v04Payload = readResource("/datadog/smoketest/backend/webflux.04.msgpack"); + backend = TraceBackend.mockAgent(); + backend.start(); + } + + @AfterAll + static void tearDown() { + if (backend != null) { + backend.close(); + } + } + + @BeforeEach + void resetTraces() { + backend.clear(); + } + + @Test + void receivesAndDecodesSubmittedTraces() throws IOException { + putTraces("/v0.4/traces", v04Payload); + + Traces traces = backend.traces(); + traces.waitForTraceCount(1); + + List decoded = traces.getTraces(); + assertEquals(1, decoded.size(), "trace count"); + + // sortByStart makes the assertion independent of the received span order (thin matcher is + // positional — see TraceMatcher's TODO). + List spans = Decoder.sortByStart(decoded.get(0).getSpans()); + assertEquals(2, spans.size(), "span count"); + + DecodedSpan root = spans.get(0); + assertEquals("smoke-test-java-app", root.getService()); + assertEquals("netty.request", root.getName()); + assertEquals("GET /hello", root.getResource()); + assertEquals(0L, root.getParentId(), "root has no parent"); + assertEquals("netty", root.getMeta().get("component")); + + DecodedSpan child = spans.get(1); + assertEquals("WebController.hello", child.getName()); + assertEquals(root.getSpanId(), child.getParentId(), "child parents the root span"); + } + + @Test + void assertTracesFacadeMatchesDecodedTraces() throws IOException { + putTraces("/v0.4/traces", v04Payload); + + // The fluent facade (S5) chains count-polling into the thin smoke matcher (S1). Both fixture + // spans share the service, so this holds regardless of the received span order. + backend + .traces() + .waitForTraceCount(1) + .assertTraces( + trace(span().service("smoke-test-java-app"), span().service("smoke-test-java-app"))); + } + + @Test + void accumulatesTracesAcrossSubmissions() throws IOException { + putTraces("/v0.4/traces", v04Payload); + putTraces("/v0.4/traces", v04Payload); + + backend.traces().waitForTraceCount(2); + assertEquals(2, backend.traces().getTraces().size()); + } + + @Test + void clearDiscardsReceivedTraces() throws IOException { + putTraces("/v0.4/traces", v04Payload); + backend.traces().waitForTraceCount(1); + + backend.clear(); + + assertTrue(backend.traces().getTraces().isEmpty(), "clear() drops collected traces"); + } + + @Test + void capturesTelemetry() throws IOException { + postTelemetry("{\"request_type\":\"app-started\",\"api_version\":\"v2\"}"); + postTelemetry( + "{\"request_type\":\"message-batch\",\"payload\":[" + + "{\"request_type\":\"app-dependencies-loaded\"}," + + "{\"request_type\":\"generate-metrics\"}]}"); + + Telemetry telemetry = backend.telemetry(); + telemetry.waitForCount(2); + assertEquals(2, telemetry.getMessages().size(), "raw messages: app-started + message-batch"); + + // getFlatMessages expands the batch into its two entries: 1 + 2 = 3. + List> flat = telemetry.getFlatMessages(); + assertEquals(3, flat.size(), "message-batch expanded into its entries"); + assertTrue( + flat.stream().anyMatch(m -> "app-started".equals(m.get("request_type"))), + "app-started present"); + assertTrue( + flat.stream().anyMatch(m -> "app-dependencies-loaded".equals(m.get("request_type"))), + "batch entry present"); + + backend.clear(); + assertTrue(backend.telemetry().getMessages().isEmpty(), "clear() drops telemetry too"); + } + + @Test + void waitForFlatMatchesTelemetryEvents() throws IOException { + postTelemetry("{\"request_type\":\"app-started\",\"api_version\":\"v2\"}"); + + // Matches a received event… + backend.telemetry().waitForFlat(message -> "app-started".equals(message.get("request_type"))); + // …and times out (short timeout) when nothing matches. + assertThrows( + AssertionError.class, + () -> + backend + .telemetry() + .waitForFlat(message -> "never-sent".equals(message.get("request_type")), 0.2)); + } + + @Test + void infoAdvertisesTraceEndpoints() throws IOException { + Request request = new Request.Builder().url(backend.url() + "/info").get().build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertEquals(200, response.code()); + String body = response.body().string(); + assertTrue(body.contains("/v0.4/traces"), body); + } + } + + @Test + void waitForTraceCountTimesOutWhenTooFew() { + // Nothing submitted, so polling for a trace with a short timeout must fail rather than hang. + assertThrows( + AssertionError.class, () -> backend.traces().waitForTraceCount(1, 0.2 /* seconds */)); + } + + @Test + void exposesBoundPort() { + assertTrue(backend.port() > 0, "port is bound"); + assertEquals(backend.url().getPort(), backend.port(), "port matches url"); + } + + private static void postTelemetry(String json) throws IOException { + Request request = + new Request.Builder() + .url(backend.url() + "/telemetry/proxy/api/v2/apmtelemetry") + .post(RequestBody.create(JSON, json)) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertTrue(response.isSuccessful(), "mock agent should accept telemetry: " + response.code()); + } + } + + private static void putTraces(String path, byte[] payload) throws IOException { + Request request = + new Request.Builder() + .url(backend.url() + path) + .put(RequestBody.create(MSGPACK, payload)) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertEquals(200, response.code(), "mock agent should accept trace submissions"); + } + } + + private static byte[] readResource(String name) throws IOException { + try (InputStream in = MockAgentBackendTest.class.getResourceAsStream(name)) { + assertNotNull(in, "missing test resource " + name); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return out.toByteArray(); + } + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendContainerTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendContainerTest.java new file mode 100644 index 00000000000..ab2c5c8dce4 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendContainerTest.java @@ -0,0 +1,175 @@ +package datadog.smoketest.backend; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedTrace; +import datadog.trace.test.agent.decoder.Decoder; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.util.List; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * End-to-end tests for {@link TestAgentBackend} against a real dd-apm-test-agent container. Skipped + * (aborted) when Docker is unavailable. Simulates a launched app by submitting the recorded v0.4 + * msgpack payload to {@code /v0.4/traces} with the backend's session-token header, then verifies + * the backend reads and decodes exactly that session's traces via {@code /test/session/*} (S3a / + * Q4a). + * + *

Uses the public {@code ghcr.io} image so it runs without internal-registry access; real smoke + * tests default to the CI mirror (see {@link TestAgentBackend}). + */ +@EnabledIfDockerAvailable +class TestAgentBackendContainerTest { + private static final String PUBLIC_IMAGE = + "ghcr.io/datadog/dd-apm-test-agent/ddapm-test-agent:v1.44.0"; + private static final MediaType MSGPACK = MediaType.parse("application/msgpack"); + private static final OkHttpClient CLIENT = new OkHttpClient(); + + private static byte[] v04Payload; + private static TestAgentBackend backend; + + @BeforeAll + static void setUp() throws IOException { + v04Payload = readResource("/datadog/smoketest/backend/webflux.04.msgpack"); + backend = TraceBackend.testAgentBuilder().image(PUBLIC_IMAGE).build(); + backend.start(); + } + + @AfterAll + static void tearDown() { + if (backend != null) { + backend.close(); + } + } + + @BeforeEach + void freshSession() { + if (backend != null) { + backend.clear(); + } + } + + @Test + void capturesSessionScopedTraces() throws IOException { + submitAppTraces(backend.url(), backend.sessionToken(), v04Payload); + + Traces traces = backend.traces(); + traces.waitForTraceCount(1); + + List decoded = traces.getTraces(); + assertEquals(1, decoded.size(), "trace count"); + + List spans = Decoder.sortByStart(decoded.get(0).getSpans()); + assertEquals(2, spans.size(), "span count"); + DecodedSpan root = spans.get(0); + assertEquals("smoke-test-java-app", root.getService()); + assertEquals("netty.request", root.getName()); + assertEquals("GET /hello", root.getResource()); + assertEquals(0L, root.getParentId(), "root has no parent"); + assertEquals(root.getSpanId(), spans.get(1).getParentId(), "child parents the root"); + } + + @Test + void clearStartsAFreshSession() throws IOException { + submitAppTraces(backend.url(), backend.sessionToken(), v04Payload); + backend.traces().waitForTraceCount(1); + + backend.clear(); + + assertTrue(backend.traces().getTraces().isEmpty(), "clear() opens an empty session"); + } + + @Test + void externalBackendReadsFromRunningAgent() throws IOException { + // Point an .external() backend at the same running container: exercises the external code path + // (no container of its own) and, via its own fresh token, that sessions are isolated. + TestAgentBackend external = + TraceBackend.testAgentBuilder().external(backend.url().getHost(), backend.port()).build(); + external.start(); + try { + submitAppTraces(external.url(), external.sessionToken(), v04Payload); + + external.traces().waitForTraceCount(1); + assertEquals(1, external.traces().getTraces().size(), "external reads only its own session"); + } finally { + external.close(); + } + } + + @Test + void capturesTelemetry() throws IOException { + // Post a telemetry app-started message; the backend reads it back from /test/apmtelemetry. + HttpUrl url = + HttpUrl.get(backend.url()) + .newBuilder() + .addPathSegments("telemetry/proxy/api/v2/apmtelemetry") + .build(); + Request request = + new Request.Builder() + .url(url) + .header("DD-Telemetry-API-Version", "v2") + .header("DD-Telemetry-Request-Type", "app-started") + .header("X-Datadog-Test-Session-Token", backend.sessionToken()) + .post( + RequestBody.create( + MediaType.parse("application/json"), + "{\"request_type\":\"app-started\",\"api_version\":\"v2\"," + + "\"runtime_id\":\"r1\",\"seq_id\":1,\"payload\":{}}")) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertTrue(response.isSuccessful(), "telemetry accepted: HTTP " + response.code()); + } + + Telemetry telemetry = backend.telemetry(); + telemetry.waitForCount(1); + assertTrue( + telemetry.getFlatMessages().stream() + .anyMatch(message -> "app-started".equals(message.get("request_type"))), + "app-started telemetry captured"); + } + + private static void submitAppTraces(URI agentUrl, String token, byte[] payload) + throws IOException { + HttpUrl url = HttpUrl.get(agentUrl).newBuilder().addPathSegments("v0.4/traces").build(); + Request request = + new Request.Builder() + .url(url) + .header("X-Datadog-Trace-Count", "1") + .header("Datadog-Meta-Tracer-Version", "0.0.0-smoke-test") + .header("X-Datadog-Test-Session-Token", token) + .put(RequestBody.create(MSGPACK, payload)) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertTrue( + response.isSuccessful(), "test agent accepts trace submission: HTTP " + response.code()); + } + } + + private static byte[] readResource(String name) throws IOException { + try (InputStream in = TestAgentBackendContainerTest.class.getResourceAsStream(name)) { + assertNotNull(in, "missing test resource " + name); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + return out.toByteArray(); + } + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendTest.java new file mode 100644 index 00000000000..c82f9d7018f --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendTest.java @@ -0,0 +1,105 @@ +package datadog.smoketest.backend; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.agent.test.server.http.JavaTestHttpServer; +import org.junit.jupiter.api.Test; + +/** + * Docker-free unit tests for {@link TestAgentBackend} configuration/lifecycle guards. The container + * and session-capture behavior is exercised end-to-end against a real agent in {@link + * TestAgentBackendContainerTest}. + */ +class TestAgentBackendTest { + + @Test + void sessionTokenIsStableAndNonEmpty() { + TestAgentBackend backend = TraceBackend.testAgentBuilder().build(); + String token = backend.sessionToken(); + assertNotNull(token); + assertFalse(token.isEmpty()); + assertEquals(token, backend.sessionToken(), "token is stable across calls"); + } + + @Test + void sessionTokenCanBeOverridden() { + assertEquals( + "fixed-token", + TraceBackend.testAgentBuilder().sessionToken("fixed-token").build().sessionToken(), + "explicit token wins over the auto-generated one"); + } + + @Test + void sharedFlagDefaultsFalseAndIsSettable() { + assertFalse(TraceBackend.testAgentBuilder().build().isShared(), "not shared by default"); + assertTrue(TraceBackend.testAgentBuilder().shared().build().isShared(), "shared() opts in"); + } + + @Test + void accessBeforeStartFails() { + TestAgentBackend backend = TraceBackend.testAgentBuilder().build(); + assertThrows(IllegalStateException.class, backend::url, "url() before start()"); + assertThrows(IllegalStateException.class, backend::port, "port() before start()"); + } + + @Test + void assertNoInvariantFailuresPassesWhenAgentReportsNoFailures() { + // A stub agent for /test/session/* and /test/trace_check/failures verifies the check logic + // without Docker; HTTP 200 from the failures endpoint means all checks passed. + try (JavaTestHttpServer agent = stubAgent(200, "")) { + TestAgentBackend backend = + TraceBackend.testAgentBuilder() + .external(agent.getAddress().getHost(), agent.getAddress().getPort()) + .build(); + backend.start(); + try { + backend.assertNoInvariantFailures(); // HTTP 200 => no failures => no throw + } finally { + backend.close(); + } + } + } + + @Test + void assertNoInvariantFailuresThrowsWhenAgentReportsFailures() { + try (JavaTestHttpServer agent = stubAgent(400, "span_count check failed")) { + TestAgentBackend backend = + TraceBackend.testAgentBuilder() + .external(agent.getAddress().getHost(), agent.getAddress().getPort()) + .build(); + backend.start(); + try { + AssertionError error = + assertThrows(AssertionError.class, backend::assertNoInvariantFailures); + assertTrue(error.getMessage().contains("span_count check failed"), error.getMessage()); + } finally { + backend.close(); + } + } + } + + /** A stub test agent: 200 on {@code /test/session/start}, {@code failuresStatus} on failures. */ + private static JavaTestHttpServer stubAgent(int failuresStatus, String failuresBody) { + return JavaTestHttpServer.httpServer( + server -> + server.handlers( + handlers -> { + handlers.prefix( + "/test/session/start", api -> api.getResponse().status(200).send()); + handlers.prefix( + "/test/trace_check/failures", + api -> { + if (failuresBody.isEmpty()) { + api.getResponse().status(failuresStatus).send(); + } else { + api.getResponse().status(failuresStatus).send(failuresBody); + } + }); + handlers.all(api -> api.getResponse().status(200).send()); + })); + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentTraceDecoderTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentTraceDecoderTest.java new file mode 100644 index 00000000000..bbe30d7280f --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentTraceDecoderTest.java @@ -0,0 +1,182 @@ +package datadog.smoketest.backend; + +import static datadog.smoketest.trace.SmokeTraceAssertions.assertTraces; +import static datadog.smoketest.trace.SpanMatcher.span; +import static datadog.smoketest.trace.TraceMatcher.trace; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedTrace; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link TestAgentTraceDecoder}: the JSON {@code /test/traces} body must decode into + * the same {@link DecodedTrace}/{@link DecodedSpan} model the msgpack {@code Decoder} yields for + * the mock backend, so the thin smoke matcher ({@code datadog.smoketest.trace}) works uniformly + * across both backends (S1b / Q1). + */ +class TestAgentTraceDecoderTest { + + // A representative /test/traces body: an array of traces, each an array of v0.4-shaped spans. + private static final String TWO_TRACES = + "[" + + " [" + + " {" + + " \"service\": \"my-service\"," + + " \"name\": \"servlet.request\"," + + " \"resource\": \"GET /greeting\"," + + " \"type\": \"web\"," + + " \"trace_id\": 1234567890," + + " \"span_id\": 111," + + " \"parent_id\": 0," + + " \"start\": 1600000000000000000," + + " \"duration\": 500000," + + " \"error\": 0," + + " \"meta\": {\"http.method\": \"GET\", \"http.status_code\": \"200\"}," + + " \"metrics\": {\"_dd.top_level\": 1, \"_dd.agent_psr\": 0.75}" + + " }," + + " {" + + " \"service\": \"my-service\"," + + " \"name\": \"repository.query\"," + + " \"resource\": \"SELECT users\"," + + " \"type\": \"sql\"," + + " \"trace_id\": 1234567890," + + " \"span_id\": 222," + + " \"parent_id\": 111," + + " \"start\": 1600000000000100000," + + " \"duration\": 200000," + + " \"error\": 1," + + " \"meta\": {\"db.type\": \"postgres\"}," + + " \"metrics\": {}" + + " }" + + " ]," + + " [" + + " {" + + " \"service\": \"batch\"," + + " \"name\": \"scheduled.job\"," + + " \"resource\": \"nightly\"," + + " \"type\": \"custom\"," + + " \"trace_id\": 42," + + " \"span_id\": 7," + + " \"parent_id\": 0," + + " \"start\": 1," + + " \"duration\": 1," + + " \"error\": 0," + + " \"meta\": {}," + + " \"metrics\": {}" + + " }" + + " ]" + + "]"; + + @Test + void decodesTraceAndSpanStructure() { + List traces = TestAgentTraceDecoder.decode(TWO_TRACES); + + assertEquals(2, traces.size(), "trace count"); + assertEquals(2, traces.get(0).getSpans().size(), "spans in first trace"); + assertEquals(1, traces.get(1).getSpans().size(), "spans in second trace"); + + DecodedSpan root = traces.get(0).getSpans().get(0); + assertEquals("my-service", root.getService()); + assertEquals("servlet.request", root.getName()); + assertEquals("GET /greeting", root.getResource()); + assertEquals("web", root.getType()); + assertEquals(1234567890L, root.getTraceId()); + assertEquals(111L, root.getSpanId()); + assertEquals(0L, root.getParentId()); + assertEquals(1600000000000000000L, root.getStart()); + assertEquals(500000L, root.getDuration()); + assertEquals(0, root.getError()); + } + + @Test + void mapsMetaAsStringsAndMetricsAsNumbers() { + List spans = TestAgentTraceDecoder.decode(TWO_TRACES).get(0).getSpans(); + + Map meta = spans.get(0).getMeta(); + assertEquals("GET", meta.get("http.method")); + assertEquals("200", meta.get("http.status_code")); + + Map metrics = spans.get(0).getMetrics(); + // Moshi decodes every JSON number in the metrics map as a Double, matching the Number model. + assertEquals(1.0, metrics.get("_dd.top_level").doubleValue()); + assertEquals(0.75, metrics.get("_dd.agent_psr").doubleValue()); + + // A serialized error span carries error != 0; the parent link is the enclosing root span id. + DecodedSpan child = spans.get(1); + assertEquals(1, child.getError()); + assertEquals(111L, child.getParentId()); + assertEquals("postgres", child.getMeta().get("db.type")); + } + + @Test + void metaStructIsNullWhenAbsentAndAMapWhenPresent() { + // Absent meta_struct decodes to null (matching the msgpack Decoder's plain-span shape). + DecodedSpan withoutMetaStruct = + TestAgentTraceDecoder.decode(TWO_TRACES).get(0).getSpans().get(0); + assertNull(withoutMetaStruct.getMetaStruct()); + + String withMetaStruct = + "[[{" + + "\"service\": \"s\", \"name\": \"n\", \"resource\": \"r\", \"type\": \"web\"," + + "\"trace_id\": 1, \"span_id\": 1, \"parent_id\": 0, \"start\": 0, \"duration\": 0," + + "\"error\": 0, \"meta\": {}, \"metrics\": {}," + + "\"meta_struct\": {\"appsec\": {\"triggers\": 3}}" + + "}]]"; + Map metaStruct = + TestAgentTraceDecoder.decode(withMetaStruct).get(0).getSpans().get(0).getMetaStruct(); + assertNotNull(metaStruct); + assertTrue(metaStruct.containsKey("appsec")); + } + + @Test + void emptyAndNullBodiesYieldNoTraces() { + assertTrue(TestAgentTraceDecoder.decode("[]").isEmpty(), "empty array => no traces"); + // Moshi parses the JSON literal null as a null document; decode tolerates it. + assertTrue(TestAgentTraceDecoder.decode("null").isEmpty(), "null body => no traces"); + + List oneEmptyTrace = TestAgentTraceDecoder.decode("[[]]"); + assertEquals(1, oneEmptyTrace.size()); + assertTrue(oneEmptyTrace.get(0).getSpans().isEmpty(), "empty trace => no spans"); + } + + @Test + void decodedTracesFeedTheSmokeMatcher() { + // The point of S1b: the test-agent-decoded traces are matchable by the same thin smoke DSL the + // mock backend uses, since both produce DecodedTrace/DecodedSpan. + List traces = TestAgentTraceDecoder.decode(TWO_TRACES); + + assertTraces( + traces, + trace( + span() + .service("my-service") + .operationName("servlet.request") + .resourceName("GET /greeting") + .type("web") + .error(false) + .root() + .tag("http.method", "GET"), + span() + .service("my-service") + .operationName("repository.query") + .resourceName("SELECT users") + .error(true) + .childOf(111L)), + trace(span().service("batch").operationName("scheduled.job").root())); + } + + @Test + void malformedJsonThrows() { + IllegalStateException e = + assertThrows( + IllegalStateException.class, () -> TestAgentTraceDecoder.decode("{ not valid json")); + assertTrue(e.getMessage().contains("/test/traces"), "message should reference the endpoint"); + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/trace/SmokeMatcherTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/trace/SmokeMatcherTest.java new file mode 100644 index 00000000000..5d04a93b4c8 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/trace/SmokeMatcherTest.java @@ -0,0 +1,160 @@ +package datadog.smoketest.trace; + +import static datadog.smoketest.trace.SmokeTraceAssertions.assertTraces; +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.assertThrows; + +import datadog.smoketest.backend.TestAgentTraceDecoder; +import datadog.trace.test.agent.decoder.DecodedTrace; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Docker-free tests for the structural matcher extensions (span sorting + {@code childOfIndex} / + * {@code childOfPrevious}, and trace-collection sorting / {@code IGNORE_ADDITIONAL_TRACES}). Traces + * are built synthetically through the S1b JSON decoder so no backend is needed. + */ +class SmokeMatcherTest { + + // One trace, three spans forming root -> child -> grandchild, but delivered out of start order. + private static final String CHAIN_TRACE = + "[[" + + spanJson("grandchild", 300, 200, 30) + + "," + + spanJson("root", 100, 0, 10) + + "," + + spanJson("child", 200, 100, 20) + + "]]"; + + // Two single-span traces; root-b (id 400) has a smaller root span id than root-a (id 500). + private static final String TWO_TRACES = + "[[" + spanJson("root-a", 500, 0, 10) + "],[" + spanJson("root-b", 400, 0, 20) + "]]"; + + // One trace whose root has two children (not a linear chain). + private static final String BRANCHING_TRACE = + "[[" + + spanJson("root", 100, 0, 10) + + "," + + spanJson("a", 200, 100, 20) + + "," + + spanJson("b", 300, 100, 30) + + "]]"; + + @Test + void sortsSpansByStartTimeThenMatchesParentByPrevious() { + List traces = TestAgentTraceDecoder.decode(CHAIN_TRACE); + assertTraces( + traces, + trace( + TraceMatcher.SORT_BY_START_TIME, + span().operationName("root").root(), + span().operationName("child").childOfPrevious(), + span().operationName("grandchild").childOfPrevious())); + } + + @Test + void matchesParentByIndex() { + List traces = TestAgentTraceDecoder.decode(CHAIN_TRACE); + assertTraces( + traces, + trace( + TraceMatcher.SORT_BY_START_TIME, + span().operationName("root").root(), + span().operationName("child").childOfIndex(0), + span().operationName("grandchild").childOfIndex(1))); + } + + @Test + void wrongParentLinkFails() { + List traces = TestAgentTraceDecoder.decode(CHAIN_TRACE); + // grandchild's parent is child (index 1), not root (index 0). + assertThrows( + AssertionError.class, + () -> + assertTraces( + traces, + trace( + TraceMatcher.SORT_BY_START_TIME, + span().operationName("root").root(), + span().operationName("child").childOfIndex(0), + span().operationName("grandchild").childOfIndex(0)))); + } + + @Test + void ignoresAdditionalTraces() { + List traces = TestAgentTraceDecoder.decode(TWO_TRACES); + // Assert just the first received trace, ignoring the other. + assertTraces( + traces, + SmokeTraceAssertions.IGNORE_ADDITIONAL_TRACES, + trace(span().operationName("root-a").root())); + } + + @Test + void sortsByAncestryRegardlessOfStartTime() { + List traces = TestAgentTraceDecoder.decode(CHAIN_TRACE); + // Spans arrive out of start order; ancestry order recovers root -> child -> grandchild. + assertTraces( + traces, + trace( + SORT_BY_ANCESTRY, + span().operationName("root").root(), + span().operationName("child").childOfPrevious(), + span().operationName("grandchild").childOfPrevious())); + } + + @Test + void ancestryOrdersBranchingTraceParentBeforeChildrenByStart() { + List traces = TestAgentTraceDecoder.decode(BRANCHING_TRACE); + // root, then its two children in start order (a@20 before b@30); both are children of root. + assertTraces( + traces, + trace( + SORT_BY_ANCESTRY, + span().operationName("root").root(), + span().operationName("a").childOfIndex(0), + span().operationName("b").childOfIndex(0))); + } + + @Test + void unorderedMatchesAnyOrder() { + List traces = TestAgentTraceDecoder.decode(TWO_TRACES); + // Matchers in the opposite order of receipt still each find their trace (no sorter => any + // order). + assertTraces( + traces, + options -> options.unorder().ignoreAdditionalTraces(), + trace(span().operationName("root-b").root()), + trace(span().operationName("root-a").root())); + } + + @Test + void unorderedRequiresDistinctTraces() { + List traces = TestAgentTraceDecoder.decode(TWO_TRACES); + // Two matchers for the same trace can't both match: only one root-a trace exists. + assertThrows( + AssertionError.class, + () -> + assertTraces( + traces, + options -> options.unorder().ignoreAdditionalTraces(), + trace(span().operationName("root-a").root()), + trace(span().operationName("root-a").root()))); + } + + private static String spanJson(String name, long id, long parent, long start) { + return "{\"service\":\"s\",\"name\":\"" + + name + + "\",\"resource\":\"" + + name + + "\",\"type\":\"web\",\"trace_id\":1,\"span_id\":" + + id + + ",\"parent_id\":" + + parent + + ",\"start\":" + + start + + ",\"duration\":1,\"error\":0,\"meta\":{},\"metrics\":{}}"; + } +} diff --git a/dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack b/dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack new file mode 100644 index 00000000000..6d4863a3b0f Binary files /dev/null and b/dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack differ diff --git a/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java b/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java index 9faf4f4ea8e..0ab030189b5 100644 --- a/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java +++ b/dd-trace-api/src/main/java/datadog/trace/api/config/TracerConfig.java @@ -32,6 +32,7 @@ public final class TracerConfig { public static final String PROXY_NO_PROXY = "proxy.no_proxy"; public static final String TRACE_AGENT_PATH = "trace.agent.path"; public static final String TRACE_AGENT_ARGS = "trace.agent.args"; + public static final String TEST_AGENT_SESSION_TOKEN = "test.agent.session.token"; public static final String PRIORITY_SAMPLING = "priority.sampling"; public static final String PRIORITY_SAMPLING_FORCE = "priority.sampling.force"; @Deprecated public static final String TRACE_RESOLVER_ENABLED = "trace.resolver.enabled"; diff --git a/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/DDAgentApi.java b/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/DDAgentApi.java index 8907e634cfc..3da99de173f 100644 --- a/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/DDAgentApi.java +++ b/dd-trace-core/src/main/java/datadog/trace/common/writer/ddagent/DDAgentApi.java @@ -38,6 +38,8 @@ public class DDAgentApi extends RemoteApi { private static final String DATADOG_CLIENT_COMPUTED_TOP_LEVEL = "Datadog-Client-Computed-Top-Level"; private static final String X_DATADOG_TRACE_COUNT = "X-Datadog-Trace-Count"; + // Optional test-agent session + private static final String X_DATADOG_TEST_SESSION_TOKEN = "X-Datadog-Test-Session-Token"; private static final String DATADOG_DROPPED_TRACE_COUNT = "Datadog-Client-Dropped-P0-Traces"; private static final String DATADOG_DROPPED_SPAN_COUNT = "Datadog-Client-Dropped-P0-Spans"; private static final String DATADOG_AGENT_STATE = "Datadog-Agent-State"; @@ -79,6 +81,10 @@ public DDAgentApi( this.headers = new HashMap<>(); this.headers.put(DATADOG_CLIENT_COMPUTED_TOP_LEVEL, "true"); this.headers.put(DATADOG_META_TRACER_VERSION, DDTraceCoreInfo.VERSION); + String testSessionToken = Config.get().getTestAgentSessionToken(); + if (testSessionToken != null) { + this.headers.put(X_DATADOG_TEST_SESSION_TOKEN, testSessionToken); + } } public void addResponseListener(final RemoteResponseListener listener) { diff --git a/internal-api/src/main/java/datadog/trace/api/Config.java b/internal-api/src/main/java/datadog/trace/api/Config.java index 7d6df22df1c..ec51403b85b 100644 --- a/internal-api/src/main/java/datadog/trace/api/Config.java +++ b/internal-api/src/main/java/datadog/trace/api/Config.java @@ -669,6 +669,7 @@ import static datadog.trace.api.config.TracerConfig.SPAN_SAMPLING_RULES_FILE; import static datadog.trace.api.config.TracerConfig.SPAN_TAGS; import static datadog.trace.api.config.TracerConfig.SPLIT_BY_TAGS; +import static datadog.trace.api.config.TracerConfig.TEST_AGENT_SESSION_TOKEN; import static datadog.trace.api.config.TracerConfig.TRACE_128_BIT_TRACEID_GENERATION_ENABLED; import static datadog.trace.api.config.TracerConfig.TRACE_AGENT_ARGS; import static datadog.trace.api.config.TracerConfig.TRACE_AGENT_PATH; @@ -1365,6 +1366,7 @@ public static String getHostName() { private final boolean azureFunctions; private final boolean awsServerless; private final String traceAgentPath; + private final String testAgentSessionToken; private final List traceAgentArgs; private final String dogStatsDPath; private final List dogStatsDArgs; @@ -3105,6 +3107,7 @@ PROFILING_DATADOG_PROFILER_ENABLED, isDatadogProfilerSafeInCurrentEnvironment()) azureAppServices = configProvider.getBoolean(AZURE_APP_SERVICES, false); traceAgentPath = configProvider.getString(TRACE_AGENT_PATH); + testAgentSessionToken = configProvider.getString(TEST_AGENT_SESSION_TOKEN); String traceAgentArgsString = configProvider.getString(TRACE_AGENT_ARGS); if (traceAgentArgsString == null) { traceAgentArgs = Collections.emptyList(); @@ -5028,6 +5031,10 @@ public String getTraceAgentPath() { return traceAgentPath; } + public String getTestAgentSessionToken() { + return testAgentSessionToken; + } + public List getTraceAgentArgs() { return traceAgentArgs; } diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json index d6b02fbfa28..44ad3c64c75 100644 --- a/metadata/supported-configurations.json +++ b/metadata/supported-configurations.json @@ -4065,6 +4065,14 @@ "aliases": [] } ], + "DD_TEST_AGENT_SESSION_TOKEN": [ + { + "version": "A", + "type": "string", + "default": null, + "aliases": [] + } + ], "DD_TEST_FAILED_TEST_REPLAY_ENABLED": [ { "version": "A", diff --git a/telemetry/src/main/java/datadog/telemetry/TelemetryClient.java b/telemetry/src/main/java/datadog/telemetry/TelemetryClient.java index c13411e0e69..445c03f63dd 100644 --- a/telemetry/src/main/java/datadog/telemetry/TelemetryClient.java +++ b/telemetry/src/main/java/datadog/telemetry/TelemetryClient.java @@ -65,11 +65,13 @@ private static String buildIntakeTelemetryUrl(Config config) { private static final String AGENT_TELEMETRY_API_ENDPOINT = "telemetry/proxy/api/v2/apmtelemetry"; private static final String DD_TELEMETRY_REQUEST_TYPE = "DD-Telemetry-Request-Type"; + private static final String X_DATADOG_TEST_SESSION_TOKEN = "X-Datadog-Test-Session-Token"; private final OkHttpClient okHttpClient; private final HttpRetryPolicy.Factory httpRetryPolicy; private final HttpUrl url; private final String apiKey; + private final String testSessionToken; public TelemetryClient( OkHttpClient okHttpClient, @@ -80,6 +82,7 @@ public TelemetryClient( this.httpRetryPolicy = httpRetryPolicy; this.url = url; this.apiKey = apiKey; + this.testSessionToken = Config.get().getTestAgentSessionToken(); } public HttpUrl getUrl() { @@ -90,6 +93,8 @@ public Result sendHttpRequest(Request.Builder httpRequestBuilder) { httpRequestBuilder.url(url); if (apiKey != null) { httpRequestBuilder.addHeader("DD-API-KEY", apiKey); + } else if (testSessionToken != null && !testSessionToken.isEmpty()) { + httpRequestBuilder.addHeader(X_DATADOG_TEST_SESSION_TOKEN, testSessionToken); } Request httpRequest = httpRequestBuilder.build();