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/communication/src/main/java/datadog/communication/ddagent/SharedCommunicationObjects.java b/communication/src/main/java/datadog/communication/ddagent/SharedCommunicationObjects.java index 9468a8a1f93..73e9fbc037c 100644 --- a/communication/src/main/java/datadog/communication/ddagent/SharedCommunicationObjects.java +++ b/communication/src/main/java/datadog/communication/ddagent/SharedCommunicationObjects.java @@ -26,6 +26,8 @@ public class SharedCommunicationObjects { private static final Logger log = LoggerFactory.getLogger(SharedCommunicationObjects.class); + private static final String X_DATADOG_TEST_SESSION_TOKEN = "X-Datadog-Test-Session-Token"; + private final List pausedComponents = new ArrayList<>(); private volatile boolean paused; @@ -90,9 +92,27 @@ public void createRemaining(Config config) { agentHttpClient = OkHttpUtils.buildHttpClient( OkHttpUtils.isPlainHttp(agentUrl), unixDomainSocket, namedPipe, httpClientTimeout); + String testSessionToken = config.getTestAgentSessionToken(); + if (testSessionToken != null) { + agentHttpClient = injectTestAgentSessionHeaderInterceptor(testSessionToken); + } } } + private OkHttpClient injectTestAgentSessionHeaderInterceptor(String testSessionToken) { + return agentHttpClient + .newBuilder() + .addInterceptor( + chain -> + chain.proceed( + chain + .request() + .newBuilder() + .header(X_DATADOG_TEST_SESSION_TOKEN, testSessionToken) + .build())) + .build(); + } + /** Registers a callback to be called when remote communications resume. */ public void whenReady(Runnable callback) { if (paused) { diff --git a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/SpanMatcher.java b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/SpanMatcher.java index 693edfac39d..590792102c5 100644 --- a/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/SpanMatcher.java +++ b/dd-java-agent/instrumentation-testing/src/main/java/datadog/trace/agent/test/assertions/SpanMatcher.java @@ -451,6 +451,7 @@ private void assertSpanLinks(List links) { .expected(expectedLinkCount) .actual(linkCount) .buildAndThrow(); + return; } for (int i = 0; i < expectedLinkCount; i++) { SpanLinkMatcher linkMatcher = this.linkMatchers[i]; diff --git a/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/ExtendedDataCollectionSmokeTest.groovy b/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/ExtendedDataCollectionSmokeTest.groovy index dc97e26ba11..99cfbf73327 100644 --- a/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/ExtendedDataCollectionSmokeTest.groovy +++ b/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/ExtendedDataCollectionSmokeTest.groovy @@ -303,7 +303,7 @@ class ExtendedDataCollectionSmokeTest extends AbstractAppSecServerSmokeTest { } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct != null + !rootSpan.span.metaStruct.isEmpty() def requestBody = rootSpan.span.metaStruct.get('http.request.body') assert requestBody != null, 'request body is not set' !rootSpan.meta.containsKey('_dd.appsec.request_body_size.exceeded') @@ -343,7 +343,7 @@ class ExtendedDataCollectionSmokeTest extends AbstractAppSecServerSmokeTest { } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct != null + !rootSpan.span.metaStruct.isEmpty() def requestBody = rootSpan.span.metaStruct.get('http.request.body') assert requestBody != null, 'request body is not set' !rootSpan.meta.containsKey('_dd.appsec.request_body_size.exceeded') @@ -383,7 +383,7 @@ class ExtendedDataCollectionSmokeTest extends AbstractAppSecServerSmokeTest { } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct != null + !rootSpan.span.metaStruct.isEmpty() def requestBody = rootSpan.span.metaStruct.get('http.request.body') assert requestBody != null, 'request body is not set' rootSpan.meta.containsKey('_dd.appsec.request_body_size.exceeded') @@ -421,7 +421,7 @@ class ExtendedDataCollectionSmokeTest extends AbstractAppSecServerSmokeTest { } assert trigger == null, 'test trigger found' - rootSpan.span.metaStruct == null + rootSpan.span.metaStruct.isEmpty() } void 'test request body collection if WAF event with default-config'(){ @@ -457,7 +457,7 @@ class ExtendedDataCollectionSmokeTest extends AbstractAppSecServerSmokeTest { } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct != null + !rootSpan.span.metaStruct.isEmpty() def requestBody = rootSpan.span.metaStruct.get('http.request.body') assert requestBody != null, 'request body is not set' !rootSpan.meta.containsKey('_dd.appsec.request_body_size.exceeded') diff --git a/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/SpringBootSmokeTest.groovy b/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/SpringBootSmokeTest.groovy index dc573ec37ff..f5ad2d20bdd 100644 --- a/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/SpringBootSmokeTest.groovy +++ b/dd-smoke-tests/appsec/springboot/src/test/groovy/datadog/smoketest/appsec/SpringBootSmokeTest.groovy @@ -699,7 +699,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest { } } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct != null + !rootSpan.span.metaStruct.isEmpty() def stack = rootSpan.span.metaStruct.get('_dd.stack') assert stack != null, 'stack is not set' def exploit = stack.get('exploit') @@ -774,7 +774,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest { } } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct == null + rootSpan.span.metaStruct.isEmpty() where: variant | _ @@ -814,7 +814,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest { } } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct == null + rootSpan.span.metaStruct.isEmpty() where: variant | _ @@ -853,7 +853,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest { } } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct == null + rootSpan.span.metaStruct.isEmpty() } def findFirstMatchingSpan(String resource) { @@ -939,7 +939,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest { } } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct == null + rootSpan.span.metaStruct.isEmpty() where: endpoint | cmd | params @@ -990,7 +990,7 @@ class SpringBootSmokeTest extends AbstractAppSecServerSmokeTest { } } assert trigger != null, 'test trigger not found' - rootSpan.span.metaStruct == null + rootSpan.span.metaStruct.isEmpty() where: endpoint | cmd | params 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..f249a023dd4 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TestAgentBackend.java @@ -0,0 +1,324 @@ +package datadog.smoketest.backend; + +import datadog.trace.test.agent.decoder.DecodedTrace; +import datadog.trace.test.agent.decoder.Decoder; +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 Decoder#decodeJson(String)} (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 Decoder.decodeJson(execute(request, "read test-agent session traces")).getTraces(); + } + + 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/TraceBackend.java b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TraceBackend.java new file mode 100644 index 00000000000..1f50e7a037d --- /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 {@code Decoder.decodeJson} 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..cb48941c5d8 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SmokeTraceAssertions.java @@ -0,0 +1,173 @@ +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 + 1); + } + 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; + } + + /** + * Matches each matcher against any distinct trace rather than the one at its position. + * + *

Assignment is greedy: each matcher takes the first still-unmatched trace it accepts. This + * can spuriously fail when matchers accept overlapping sets of traces and a valid distinct + * assignment exists only under a different pairing. Keep matchers specific enough to be + * unambiguous. + */ + 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..d1b6ca046d5 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/trace/SpanMatcher.java @@ -0,0 +1,317 @@ +package datadog.smoketest.trace; + +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.Map.Entry; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * The class is a helper class to verify span attributes on the {@link DecodedSpan} model produced + * by both smoke backends (the in-process mock agent and the dd-apm-test-agent). + * + *

To get a {@code SpanMatcher}, use the static factory method {@link #span()} and use it as a + * fluent builder to define the span matching constraints. + * + *

Span matching constraints cover only what a span carries once serialized: + * + *

    + *
  • span service name with {@link #service(String)} + *
  • span operation name with {@link #operationName(String)} and {@link #operationName(Pattern)} + *
  • span resource name with {@link #resourceName(String)}, {@link #resourceName(Pattern)}, and + * {@link #resourceName(Predicate)} + *
  • span type with {@link #type(String)} + *
  • span error status with {@link #error(boolean)} + *
  • span parent linkage with {@link #root()}, {@link #childOf(long)}, {@link + * #childOfIndex(int)}, and {@link #childOfPrevious()} + *
  • span meta (string) tags with {@link #tag(String, Matcher)} and {@link #tag(String, String)} + *
  • span metrics (numeric) tags with {@link #metric(String, Matcher)} + *
  • span meta_struct (nested structured data) with {@link #metaStruct(String, Matcher)} + *
+ */ +public final class SpanMatcher { + private Matcher serviceMatcher; + private Matcher operationNameMatcher; + private Matcher resourceNameMatcher; + private Matcher typeMatcher; + private Matcher errorMatcher; + private Matcher parentIdMatcher; + private int parentSpanIndex; + private final Map> metaMatchers; + private final Map> metricMatchers; + private final Map> metaStructMatchers; + + private static final Matcher CHILD_OF_PREVIOUS_MATCHER = is(0L); + + private SpanMatcher() { + this.parentSpanIndex = -1; + this.metaMatchers = new HashMap<>(); + this.metricMatchers = new HashMap<>(); + this.metaStructMatchers = new HashMap<>(); + } + + /** + * Checks a span and its attributes. + * + * @return A new {@link SpanMatcher} instance to configure span matching constraints. + */ + public static SpanMatcher span() { + return new SpanMatcher(); + } + + /** + * Checks the span service name matches the given value. + * + * @param service The service name to match against. + * @return The current {@link SpanMatcher} instance updated with the specified service name + * constraint. + */ + public SpanMatcher service(String service) { + this.serviceMatcher = is(service); + return this; + } + + /** + * Checks the span operation name matches the given value. + * + * @param operationName The operation name to match against. + * @return The current {@link SpanMatcher} instance updated with the specified operation name + * constraint. + */ + public SpanMatcher operationName(String operationName) { + this.operationNameMatcher = is(operationName); + return this; + } + + /** + * Checks the span operation name matches the provided regular expression pattern. + * + * @param pattern The {@link Pattern} to match the operation name against. + * @return The current {@link SpanMatcher} instance updated with the specified operation name + * constraint. + */ + public SpanMatcher operationName(Pattern pattern) { + this.operationNameMatcher = matches(pattern); + return this; + } + + /** + * Checks the span resource name matches the given value. + * + * @param resourceName The resource name to match against. + * @return The current {@link SpanMatcher} instance updated with the specified resource name + * constraint. + */ + public SpanMatcher resourceName(String resourceName) { + this.resourceNameMatcher = is(resourceName); + return this; + } + + /** + * Checks the span resource name matches the provided regular expression pattern. + * + * @param pattern The {@link Pattern} used to match the resource name against. + * @return The current {@link SpanMatcher} instance updated with the specified resource name + * constraint. + */ + public SpanMatcher resourceName(Pattern pattern) { + this.resourceNameMatcher = matches(pattern); + return this; + } + + /** + * Checks the span resource name matches the provided validator. + * + * @param validator The {@link Predicate} used to validate the resource name. + * @return The current {@link SpanMatcher} instance updated with the specified resource name + * constraint. + */ + public SpanMatcher resourceName(Predicate validator) { + this.resourceNameMatcher = validates(validator); + return this; + } + + /** + * Checks the span type matches the given value. + * + * @param type The span type to match against. + * @return The current {@link SpanMatcher} instance updated with the specified span type + * constraint. + */ + public SpanMatcher type(String type) { + this.typeMatcher = is(type); + return this; + } + + /** + * Checks the span error status matches the given value. + * + * @param errored The expected error status. + * @return The current {@link SpanMatcher} instance updated with the specified error constraint. + */ + public SpanMatcher error(boolean errored) { + this.errorMatcher = errored ? isTrue() : isFalse(); + return this; + } + + /** + * Checks the span is a root span (i.e., a span with no parent). + * + * @return The current {@link SpanMatcher} instance with the root constraint applied. + */ + public SpanMatcher root() { + this.parentIdMatcher = is(0L); + this.parentSpanIndex = -1; + return this; + } + + /** + * Checks the span is a direct child of the specified parent span. + * + * @param parentSpanId The identifier of the parent span to match against. + * @return The current {@link SpanMatcher} instance with the child-of constraint applied. + */ + public SpanMatcher childOf(long parentSpanId) { + this.parentIdMatcher = is(parentSpanId); + this.parentSpanIndex = -1; + return this; + } + + /** + * Checks the span is a direct child of the span at the specified index in the trace. + * + * @param parentSpanIndex The index of the parent span in the trace. + * @return The current {@link SpanMatcher} instance with the child-of constraint applied. + */ + public SpanMatcher childOfIndex(int parentSpanIndex) { + this.parentIdMatcher = null; + this.parentSpanIndex = parentSpanIndex; + return this; + } + + /** + * Checks the span is a direct child of the immediately preceding span in the trace. + * + * @return The current {@link SpanMatcher} instance with the child-of constraint applied. + */ + public SpanMatcher childOfPrevious() { + this.parentIdMatcher = CHILD_OF_PREVIOUS_MATCHER; + this.parentSpanIndex = -1; + return this; + } + + /** + * Checks the span meta (string) tag matches the given matcher. + * + * @param name The name of the meta tag to match against. + * @param matcher The matcher to check the meta tag value. + * @return The current {@link SpanMatcher} instance updated with the specified meta tag + * constraint. + */ + public SpanMatcher tag(String name, Matcher matcher) { + this.metaMatchers.put(name, matcher); + return this; + } + + /** + * Checks the span meta (string) tag matches the given value. + * + * @param name The name of the meta tag to match against. + * @param value The expected meta tag value. + * @return The current {@link SpanMatcher} instance updated with the specified meta tag + * constraint. + */ + public SpanMatcher tag(String name, String value) { + this.metaMatchers.put(name, is(value)); + return this; + } + + /** + * Checks the span metric (numeric) tag matches the given matcher. + * + * @param name The name of the metric tag to match against. + * @param matcher The matcher to check the metric tag value. + * @return The current {@link SpanMatcher} instance updated with the specified metric tag + * constraint. + */ + public SpanMatcher metric(String name, Matcher matcher) { + this.metricMatchers.put(name, matcher); + return this; + } + + /** + * Checks the span meta_struct entry matches the given matcher. + * + * @param name The name of the meta_struct entry to match against. + * @param matcher The matcher to check the meta_struct entry value. + * @return The current {@link SpanMatcher} instance updated with the specified meta_struct + * constraint. + */ + public SpanMatcher metaStruct(String name, Matcher matcher) { + this.metaStructMatchers.put(name, matcher); + return this; + } + + void assertSpan(List trace, int spanIndex) { + DecodedSpan span = trace.get(spanIndex); + 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"); + assertValue(parentIdMatcher(trace, spanIndex), span.getParentId(), "Unexpected parent id"); + assertSpanTags(span.getMeta()); + assertSpanMetrics(span.getMetrics()); + assertSpanMetaStruct(span.getMetaStruct()); + } + + private Matcher parentIdMatcher(List trace, int spanIndex) { + if (this.parentSpanIndex >= 0) { + return is(trace.get(this.parentSpanIndex).getSpanId()); + } else if (this.parentIdMatcher == CHILD_OF_PREVIOUS_MATCHER) { + if (spanIndex == 0) { + throw new IllegalStateException("Cannot use childOfPrevious() matcher on the first span"); + } + return is(trace.get(spanIndex - 1).getSpanId()); + } else { + return this.parentIdMatcher; + } + } + + private void assertSpanTags(Map meta) { + for (Entry> entry : this.metaMatchers.entrySet()) { + String key = entry.getKey(); + assertValue(entry.getValue(), meta.get(key), "Unexpected meta tag '" + key + "'"); + } + } + + private void assertSpanMetrics(Map metrics) { + for (Entry> entry : this.metricMatchers.entrySet()) { + assertValue( + entry.getValue(), + metrics.get(entry.getKey()), + "Unexpected metric '" + entry.getKey() + "'"); + } + } + + @SuppressWarnings("unchecked") + private void assertSpanMetaStruct(Map metaStruct) { + for (Entry> entry : this.metaStructMatchers.entrySet()) { + String key = entry.getKey(); + assertValue( + (Matcher) entry.getValue(), + metaStruct.get(key), + "Unexpected meta_struct '" + key + "'"); + } + } +} 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/trace/SmokeMatcherTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/trace/SmokeMatcherTest.java new file mode 100644 index 00000000000..7da9d3a90e6 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/trace/SmokeMatcherTest.java @@ -0,0 +1,246 @@ +package datadog.smoketest.trace; + +import static datadog.smoketest.trace.SmokeTraceAssertions.IGNORE_ADDITIONAL_TRACES; +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.SORT_BY_START_TIME; +import static datadog.smoketest.trace.TraceMatcher.trace; +import static datadog.trace.test.junit.utils.assertions.Matchers.isNonNull; +import static datadog.trace.test.junit.utils.assertions.Matchers.validates; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import datadog.trace.test.agent.decoder.DecodedTrace; +import datadog.trace.test.agent.decoder.Decoder; +import java.util.List; +import java.util.Map; +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) + + "]]"; + + // One span carrying meta_struct: an IAST-style nested "_dd.stack" plus a request-body entry. + private static final String META_STRUCT_TRACE = + "[[{" + + "\"service\":\"s\",\"name\":\"servlet.request\",\"resource\":\"r\",\"type\":\"web\"," + + "\"trace_id\":1,\"span_id\":1,\"parent_id\":0,\"start\":0,\"duration\":1,\"error\":0," + + "\"meta\":{},\"metrics\":{}," + + "\"meta_struct\":{" + + "\"_dd.stack\":{\"vulnerability\":[{\"type\":\"SQL_INJECTION\"},{\"type\":\"XSS\"}]}," + + "\"http.request.body\":{\"foo\":\"bar\"}" + + "}}]]"; + + @Test + void sortsSpansByStartTimeThenMatchesParentByPrevious() { + List traces = Decoder.decodeJson(CHAIN_TRACE).getTraces(); + assertTraces( + traces, + trace( + SORT_BY_START_TIME, + span().operationName("root").root(), + span().operationName("child").childOfPrevious(), + span().operationName("grandchild").childOfPrevious())); + } + + @Test + void matchesParentByIndex() { + List traces = Decoder.decodeJson(CHAIN_TRACE).getTraces(); + assertTraces( + traces, + trace( + SORT_BY_START_TIME, + span().operationName("root").root(), + span().operationName("child").childOfIndex(0), + span().operationName("grandchild").childOfIndex(1))); + } + + @Test + void wrongParentLinkFails() { + List traces = Decoder.decodeJson(CHAIN_TRACE).getTraces(); + // grandchild's parent is child (index 1), not root (index 0). + assertThrows( + AssertionError.class, + () -> + assertTraces( + traces, + trace( + SORT_BY_START_TIME, + span().operationName("root").root(), + span().operationName("child").childOfIndex(0), + span().operationName("grandchild").childOfIndex(0)))); + } + + @Test + void ignoresAdditionalTraces() { + List traces = Decoder.decodeJson(TWO_TRACES).getTraces(); + // Assert just the first received trace, ignoring the other. + assertTraces(traces, IGNORE_ADDITIONAL_TRACES, trace(span().operationName("root-a").root())); + } + + @Test + void sortsByAncestryRegardlessOfStartTime() { + List traces = Decoder.decodeJson(CHAIN_TRACE).getTraces(); + // 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 = Decoder.decodeJson(BRANCHING_TRACE).getTraces(); + // 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 = Decoder.decodeJson(TWO_TRACES).getTraces(); + // 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 = Decoder.decodeJson(TWO_TRACES).getTraces(); + // 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()))); + } + + @Test + void matchesMetaStructByMatcherAndPredicate() { + List traces = Decoder.decodeJson(META_STRUCT_TRACE).getTraces(); + assertTraces( + traces, + trace( + span() + .operationName("servlet.request") + .root() + // plain matcher: the entry is present (any nested value) + .metaStruct("http.request.body", isNonNull()) + // predicate via validates(): navigate the nested structure + .metaStruct( + "_dd.stack", + validates(v -> ((List) ((Map) v).get("vulnerability")).size() == 2)))); + } + + @Test + void metaStructMismatchFails() { + List traces = Decoder.decodeJson(META_STRUCT_TRACE).getTraces(); + // Absent entry -> null value -> matcher fails. + assertThrows( + AssertionError.class, + () -> assertTraces(traces, trace(span().root().metaStruct("does.not.exist", isNonNull())))); + // Present entry but the predicate over its nested value is not satisfied. + assertThrows( + AssertionError.class, + () -> + assertTraces( + traces, + trace( + span() + .root() + .metaStruct( + "_dd.stack", + validates( + v -> + ((List) ((Map) v).get("vulnerability")).size() + == 99))))); + } + + @Test + void ignoreAdditionalConsumesMatchedTrace() { + // Two matchers demanding the same trace must not both be satisfied by a single matching trace: + // the extra (root-b) is ignored, so once root-a is consumed the second matcher has nothing + // left. + List traces = Decoder.decodeJson(TWO_TRACES).getTraces(); + assertThrows( + AssertionError.class, + () -> + assertTraces( + traces, + IGNORE_ADDITIONAL_TRACES, + trace(span().operationName("root-a").root()), + trace(span().operationName("root-a").root()))); + } + + @Test + void childOfPreviousDoesNotLeakAcrossCandidates() { + // A broken chain (2 spans) is tried first: its childOfPrevious span resolves a concrete parent + // id and then fails. The valid chain that follows uses different span ids and must still match + // — + // the matcher must not carry the first candidate's ids over to the next. + String broken = "[" + spanJson("root", 10, 0, 10) + "," + spanJson("child", 11, 999, 20) + "]"; + String valid = "[" + spanJson("root", 20, 0, 10) + "," + spanJson("child", 21, 20, 20) + "]"; + List traces = Decoder.decodeJson("[" + broken + "," + valid + "]").getTraces(); + assertTraces( + traces, + options -> options.unorder().ignoreAdditionalTraces(), + trace( + span().operationName("root").root(), span().operationName("child").childOfPrevious())); + } + + 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/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/utils/config-utils/src/main/java/datadog/trace/api/ConfigSetting.java b/utils/config-utils/src/main/java/datadog/trace/api/ConfigSetting.java index cf77e3bfb35..35f27491157 100644 --- a/utils/config-utils/src/main/java/datadog/trace/api/ConfigSetting.java +++ b/utils/config-utils/src/main/java/datadog/trace/api/ConfigSetting.java @@ -25,7 +25,13 @@ public final class ConfigSetting { private static final Set CONFIG_FILTER_LIST = new HashSet<>( - Arrays.asList("DD_API_KEY", "dd.api-key", "dd.profiling.api-key", "dd.profiling.apikey")); + Arrays.asList( + "DD_API_KEY", + "dd.api-key", + "dd.profiling.api-key", + "dd.profiling.apikey", + "test.agent.session.token", + "DD_TEST_AGENT_SESSION_TOKEN")); public static ConfigSetting of(String key, Object value, ConfigOrigin origin) { return new ConfigSetting(key, value, origin, ABSENT_SEQ_ID, null); diff --git a/utils/config-utils/src/test/java/datadog/trace/api/ConfigSettingTest.java b/utils/config-utils/src/test/java/datadog/trace/api/ConfigSettingTest.java index 018f108b44f..1d110be1287 100644 --- a/utils/config-utils/src/test/java/datadog/trace/api/ConfigSettingTest.java +++ b/utils/config-utils/src/test/java/datadog/trace/api/ConfigSettingTest.java @@ -44,12 +44,14 @@ void supportsEqualityCheck( } @TableTest({ - "scenario | key | value | filteredValue", - "DD_API_KEY | DD_API_KEY | somevalue | ", - "dd.api-key | dd.api-key | somevalue | ", - "dd.profiling.api-key | dd.profiling.api-key | somevalue | ", - "dd.profiling.apikey | dd.profiling.apikey | somevalue | ", - "some.other.key | some.other.key | somevalue | somevalue " + "scenario | key | value | filteredValue", + "DD_API_KEY | DD_API_KEY | somevalue | ", + "dd.api-key | dd.api-key | somevalue | ", + "dd.profiling.api-key | dd.profiling.api-key | somevalue | ", + "dd.profiling.apikey | dd.profiling.apikey | somevalue | ", + "session token prop | test.agent.session.token | somevalue | ", + "session token env | DD_TEST_AGENT_SESSION_TOKEN | somevalue | ", + "some.other.key | some.other.key | somevalue | somevalue " }) void filtersKeyValues(String key, String value, String filteredValue) { assertEquals(filteredValue, ConfigSetting.of(key, value, ConfigOrigin.DEFAULT).stringValue()); diff --git a/utils/test-agent-utils/decoder/build.gradle.kts b/utils/test-agent-utils/decoder/build.gradle.kts index 844786b398b..11af097d6ce 100644 --- a/utils/test-agent-utils/decoder/build.gradle.kts +++ b/utils/test-agent-utils/decoder/build.gradle.kts @@ -8,10 +8,12 @@ extra["excludedClassesCoverage"] = listOf( "datadog.trace.test.agent.decoder.v04.raw.*", "datadog.trace.test.agent.decoder.v05.raw.*", "datadog.trace.test.agent.decoder.v1.raw.*", + "datadog.trace.test.agent.decoder.json.raw.*", ) dependencies { implementation(group = "org.msgpack", name = "msgpack-core", version = "0.8.24") + implementation(libs.moshi) testImplementation(libs.bundles.junit5) testImplementation(project(":utils:test-utils")) diff --git a/utils/test-agent-utils/decoder/gradle.lockfile b/utils/test-agent-utils/decoder/gradle.lockfile index 95b25904d6a..4826a29c7d0 100644 --- a/utils/test-agent-utils/decoder/gradle.lockfile +++ b/utils/test-agent-utils/decoder/gradle.lockfile @@ -12,6 +12,8 @@ com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.code.findbugs:jsr305:3.0.2=compileClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotations:2.41.0=spotbugs +com.squareup.moshi:moshi:1.11.0=compileClasspath,testCompileClasspath,testRuntimeClasspath +com.squareup.okio:okio:1.17.5=compileClasspath,testCompileClasspath,testRuntimeClasspath com.thoughtworks.qdox:qdox:1.12.1=codenarc commons-fileupload:commons-fileupload:1.5=testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.11.0=testCompileClasspath,testRuntimeClasspath @@ -29,7 +31,7 @@ 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 diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/Decoder.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/Decoder.java index 0cbde122e85..313584518a3 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/Decoder.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/Decoder.java @@ -1,5 +1,6 @@ package datadog.trace.test.agent.decoder; +import datadog.trace.test.agent.decoder.json.raw.MessageJson; import datadog.trace.test.agent.decoder.v04.raw.MessageV04; import datadog.trace.test.agent.decoder.v05.raw.MessageV05; import datadog.trace.test.agent.decoder.v1.raw.MessageV1; @@ -12,6 +13,11 @@ public static DecodedMessage decodeV1(byte[] buffer) { return MessageV1.unpack(buffer); } + /** Decodes the JSON trace format exposed by the dd-apm-test-agent. */ + public static DecodedMessage decodeJson(String json) { + return MessageJson.fromJson(json); + } + public static DecodedMessage decodeV05(byte[] buffer) { return MessageV05.unpack(buffer); } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java new file mode 100644 index 00000000000..10064a8ac38 --- /dev/null +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/MessageJson.java @@ -0,0 +1,61 @@ +package datadog.trace.test.agent.decoder.json.raw; + +import static java.util.Collections.emptyList; + +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.DecodedMessage; +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.List; + +/** + * MessageJson decodes a JSON trace payload — a JSON array of traces, each a JSON array of spans in + * the v0.4 shape, as exposed by the dd-apm-test-agent — into the shared {@link DecodedMessage} + * model. Unlike the msgpack formats there is no message envelope, so the payload maps directly to + * the list of traces. + */ +public final class MessageJson implements DecodedMessage { + private static final Type LIST_OF_TRACES = + Types.newParameterizedType( + List.class, Types.newParameterizedType(List.class, SpanJson.class)); + private static final JsonAdapter>> ADAPTER = + new Moshi.Builder().build().adapter(LIST_OF_TRACES); + + private final List traces; + + private MessageJson(List traces) { + this.traces = traces; + } + + /** Decodes a JSON trace payload into a {@link MessageJson}. */ + public static MessageJson fromJson(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 JSON traces: " + json, e); + } + List traces = new ArrayList<>(); + if (rawTraces != null) { + for (List spans : rawTraces) { + List decodedSpans = spans == null ? emptyList() : new ArrayList<>(spans); + traces.add(new TraceJson(decodedSpans)); + } + } + return new MessageJson(traces); + } + + @Override + public List getTraces() { + return this.traces; + } +} diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java new file mode 100644 index 00000000000..5a22e72221a --- /dev/null +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/SpanJson.java @@ -0,0 +1,145 @@ +package datadog.trace.test.agent.decoder.json.raw; + +import static java.util.Collections.emptyMap; + +import com.squareup.moshi.Json; +import datadog.trace.test.agent.decoder.DecodedSpan; +import java.util.HashMap; +import java.util.Map; + +/** + * SpanJson decodes spans from the JSON trace format the dd-apm-test-agent exposes (e.g. from its + * {@code /test/traces} endpoint), which serializes spans in the standard v0.4 shape. Field names + * mirror that wire shape: service/name/resource/type, trace_id/span_id/parent_id, + * start/duration/error, meta, metrics, and meta_struct. + */ +public final class SpanJson implements DecodedSpan { + String service; + String name; + String resource; + String type; + + // IDs are unsigned 64-bit; read as decimal strings and parsed with Long.parseUnsignedLong, since + // Moshi's long adapter rejects values above Long.MAX_VALUE (the agent emits them as JSON numbers, + // which Moshi coerces to their string form). + @Json(name = "trace_id") + String traceId; + + @Json(name = "span_id") + String spanId; + + @Json(name = "parent_id") + String 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 == null ? 0L : Long.parseUnsignedLong(this.traceId); + } + + @Override + public long getSpanId() { + return this.spanId == null ? 0L : Long.parseUnsignedLong(this.spanId); + } + + @Override + public long getParentId() { + return this.parentId == null ? 0L : Long.parseUnsignedLong(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 == null ? emptyMap() : this.meta; + } + + @Override + public Map getMetaStruct() { + return this.metaStruct == null ? emptyMap() : this.metaStruct; + } + + @Override + public Map getMetrics() { + // Moshi deserializes JSON numbers as Double; expose them as the interface's Number type. + return this.metrics == null ? emptyMap() : new HashMap<>(this.metrics); + } + + @Override + public String getType() { + return this.type; + } + + @Override + public String toString() { + return "SpanJson{" + + "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/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/TraceJson.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/TraceJson.java new file mode 100644 index 00000000000..feaa991632d --- /dev/null +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/json/raw/TraceJson.java @@ -0,0 +1,26 @@ +package datadog.trace.test.agent.decoder.json.raw; + +import static java.util.Collections.unmodifiableList; + +import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedTrace; +import java.util.List; + +/** TraceJson is a single trace (an ordered list of {@link SpanJson}) from the JSON trace format. */ +public final class TraceJson implements DecodedTrace { + private final List spans; + + TraceJson(List spans) { + this.spans = unmodifiableList(spans); + } + + @Override + public List getSpans() { + return this.spans; + } + + @Override + public String toString() { + return this.spans.toString(); + } +} diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java index bae18215d3d..9ee2e8a53e0 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v04/raw/SpanV04.java @@ -1,9 +1,11 @@ package datadog.trace.test.agent.decoder.v04.raw; +import static java.util.Collections.emptyMap; +import static java.util.Collections.unmodifiableMap; + import datadog.trace.test.agent.decoder.DecodedSpan; import java.io.IOException; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -265,9 +267,9 @@ public SpanV04( this.start = start; this.duration = duration; this.error = error; - this.meta = Collections.unmodifiableMap(meta); - this.metaStruct = metaStruct == null ? null : Collections.unmodifiableMap(metaStruct); - this.metrics = Collections.unmodifiableMap(metrics); + this.meta = unmodifiableMap(meta); + this.metaStruct = metaStruct == null ? emptyMap() : unmodifiableMap(metaStruct); + this.metrics = unmodifiableMap(metrics); this.type = type; } diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java index 3ceafa45ff5..6ac6469316f 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v05/raw/SpanV05.java @@ -1,5 +1,7 @@ package datadog.trace.test.agent.decoder.v05.raw; +import static java.util.Collections.emptyMap; + import datadog.trace.test.agent.decoder.DecodedSpan; import java.io.IOException; import java.util.Collections; @@ -190,7 +192,7 @@ public Map getMeta() { public Map getMetaStruct() { // XXX: meta_struct is not supported in v0.5. - return null; + return emptyMap(); } public Map getMetrics() { diff --git a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java index 676a450df55..6b0cbaa3bc7 100644 --- a/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java +++ b/utils/test-agent-utils/decoder/src/main/java/datadog/trace/test/agent/decoder/v1/raw/SpanV1.java @@ -1,9 +1,11 @@ package datadog.trace.test.agent.decoder.v1.raw; +import static java.util.Collections.emptyMap; +import static java.util.Collections.unmodifiableMap; + import datadog.trace.test.agent.decoder.DecodedSpan; import java.io.IOException; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -492,9 +494,9 @@ public SpanV1( this.start = start; this.duration = duration; this.error = error; - this.meta = Collections.unmodifiableMap(meta); - this.metaStruct = metaStruct == null ? null : Collections.unmodifiableMap(metaStruct); - this.metrics = Collections.unmodifiableMap(metrics); + this.meta = unmodifiableMap(meta); + this.metaStruct = metaStruct == null ? emptyMap() : unmodifiableMap(metaStruct); + this.metrics = unmodifiableMap(metrics); this.type = type; } diff --git a/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java new file mode 100644 index 00000000000..f35c677a4d0 --- /dev/null +++ b/utils/test-agent-utils/decoder/src/test/java/datadog/trace/test/agent/decoder/JsonDecoderTest.java @@ -0,0 +1,167 @@ +package datadog.trace.test.agent.decoder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link Decoder#decodeJson(String)}: the dd-apm-test-agent JSON trace body (an array of + * traces, each an array of v0.4-shaped spans) must decode into the same {@link DecodedTrace}/{@link + * DecodedSpan} model the msgpack decoders produce. + */ +class JsonDecoderTest { + + // A representative JSON trace 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 = Decoder.decodeJson(TWO_TRACES).getTraces(); + + 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 = Decoder.decodeJson(TWO_TRACES).getTraces().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 metaStructEmptyWhenAbsentAndAMapWhenPresent() { + // Absent meta_struct decodes to an empty map, matching the msgpack decoders (SpanV04). + DecodedSpan withoutMetaStruct = + Decoder.decodeJson(TWO_TRACES).getTraces().get(0).getSpans().get(0); + assertTrue(withoutMetaStruct.getMetaStruct().isEmpty()); + + 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 = + Decoder.decodeJson(withMetaStruct).getTraces().get(0).getSpans().get(0).getMetaStruct(); + assertTrue(metaStruct.containsKey("appsec")); + } + + @Test + void emptyAndNullBodiesYieldNoTraces() { + assertTrue(Decoder.decodeJson("[]").getTraces().isEmpty(), "empty array => no traces"); + // Moshi parses the JSON literal null as a null document; decode tolerates it. + assertTrue(Decoder.decodeJson("null").getTraces().isEmpty(), "null body => no traces"); + + List oneEmptyTrace = Decoder.decodeJson("[[]]").getTraces(); + assertEquals(1, oneEmptyTrace.size()); + assertTrue(oneEmptyTrace.get(0).getSpans().isEmpty(), "empty trace => no spans"); + } + + @Test + void decodesUnsignedIds() { + // Trace/span IDs are unsigned 64-bit; the agent emits them as JSON numbers that can exceed + // Long.MAX_VALUE. They must be parsed unsigned and kept as the signed bit pattern. + String maxUnsigned = Long.toUnsignedString(-1L); // 18446744073709551615 == 2^64 - 1 + String json = + "[[{" + + "\"service\": \"s\", \"name\": \"n\", \"resource\": \"r\", \"type\": \"web\"," + + "\"trace_id\": " + + maxUnsigned + + ", \"span_id\": " + + maxUnsigned + + ", \"parent_id\": 0, \"start\": 0, \"duration\": 0, \"error\": 0," + + "\"meta\": {}, \"metrics\": {}" + + "}]]"; + DecodedSpan span = Decoder.decodeJson(json).getTraces().get(0).getSpans().get(0); + assertEquals(-1L, span.getTraceId(), "unsigned 2^64-1 kept as its signed bit pattern"); + assertEquals(-1L, span.getSpanId()); + assertEquals(0L, span.getParentId()); + } + + @Test + void malformedJsonThrows() { + IllegalStateException e = + assertThrows(IllegalStateException.class, () -> Decoder.decodeJson("{ not valid json")); + assertTrue(e.getMessage().contains("{ not valid json"), "message should include the body"); + } +}