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..87e5030b5e2 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 @@ -152,6 +152,9 @@ public SpanMatcher childOfPrevious() { * @return The current {@link SpanMatcher} instance with the child-of constraint applied. */ public SpanMatcher childOfIndex(int parentSpanIndex) { + if (parentSpanIndex < 0) { + throw new AssertionFailedError("index must be >= 0"); + } this.parentIdMatcher = null; this.parentSpanIndex = parentSpanIndex; return this; @@ -451,6 +454,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/build.gradle b/dd-smoke-tests/build.gradle index 15e070ea84d..e72afbbdbf3 100644 --- a/dd-smoke-tests/build.gradle +++ b/dd-smoke-tests/build.gradle @@ -6,11 +6,11 @@ description = 'dd-smoke-tests' dependencies { api libs.okhttp + api libs.testcontainers api project(':dd-java-agent:testing') api project(':utils:test-agent-utils:decoder') compileOnly(libs.junit.jupiter) - compileOnly(libs.bundles.groovy) compileOnly(libs.bundles.spock) } diff --git a/dd-smoke-tests/dynamic-config/build.gradle b/dd-smoke-tests/dynamic-config/build.gradle index 2efcbc9e971..4869949c230 100644 --- a/dd-smoke-tests/dynamic-config/build.gradle +++ b/dd-smoke-tests/dynamic-config/build.gradle @@ -14,9 +14,13 @@ dependencies { testImplementation project(':dd-smoke-tests') testImplementation project(':utils:test-utils') + testImplementation project(':components:environment') + testImplementation project(':remote-config:remote-config-api') + testImplementation libs.testcontainers } tasks.withType(Test).configureEach { + usesService(testcontainersLimit) dependsOn "shadowJar" def shadowJarTask = tasks.named('shadowJar', ShadowJar) diff --git a/dd-smoke-tests/dynamic-config/src/test/groovy/datadog/smoketest/AppSecActivationSmokeTest.groovy b/dd-smoke-tests/dynamic-config/src/test/groovy/datadog/smoketest/AppSecActivationSmokeTest.groovy deleted file mode 100644 index 513cf008c87..00000000000 --- a/dd-smoke-tests/dynamic-config/src/test/groovy/datadog/smoketest/AppSecActivationSmokeTest.groovy +++ /dev/null @@ -1,89 +0,0 @@ -package datadog.smoketest - -import datadog.environment.JavaVirtualMachine -import datadog.remoteconfig.Capabilities -import datadog.remoteconfig.Product -import datadog.smoketest.dynamicconfig.AppSecApplication -import datadog.trace.test.util.Flaky - -class AppSecActivationSmokeTest extends AbstractSmokeTest { - - @Override - ProcessBuilder createProcessBuilder() { - def command = [javaPath()] - command += defaultJavaProperties.toList() - command += [ - '-Ddd.remote_config.enabled=true', - "-Ddd.remote_config.url=http://localhost:${server.address.port}/v0.7/config".toString(), - '-Ddd.remote_config.poll_interval.seconds=1', - '-Ddd.profiling.enabled=false', - '-cp', - System.getProperty('datadog.smoketest.shadowJar.path'), - AppSecApplication.name - ] - - final processBuilder = new ProcessBuilder(command) - processBuilder.directory(new File(buildDirectory)) - } - - @Flaky(value = "Telemetry product change event flakes in oracle8", condition = () ->JavaVirtualMachine.isOracleJDK8()) - void 'test activation via RC workflow'() { - given: - final asmRuleProducts = [Product.ASM, Product.ASM_DD, Product.ASM_DATA] - - when: 'appsec is enabled but inactive' - final request = waitForRcClientRequest { - req -> - decodeProducts(req).find { - asmRuleProducts.contains(it) - } == null - } - final capabilities = decodeCapabilities(request) - - then: 'only ASM_ACTIVATION capability should be reported' - assert hasCapability(capabilities, Capabilities.CAPABILITY_ASM_ACTIVATION) - assert !hasCapability(capabilities, Capabilities.CAPABILITY_ASM_CUSTOM_RULES) - - when: 'appsec is enabled via RC' - setRemoteConfig('datadog/2/ASM_FEATURES/asm_features_activation/config', '{"asm":{"enabled":true}}') - - then: 'we should receive a product change for appsec' - waitForTelemetryFlat { - final configurations = (List>) it?.payload?.configuration ?: [] - final enabledConfig = configurations.find { - it.name == 'DD_APPSEC_ENABLED' - } - if (!enabledConfig) { - return false - } - return enabledConfig.value == 'true' && enabledConfig .origin == 'remote_config' - } - - and: 'we should have set the capabilities for ASM rules and data' - final newRequest = waitForRcClientRequest { - req -> - decodeProducts(req).containsAll(asmRuleProducts) - } - final newCapabilities = decodeCapabilities(newRequest) - assert hasCapability(newCapabilities, Capabilities.CAPABILITY_ASM_CUSTOM_RULES) - } - - private static Set decodeProducts(final Map request) { - return request.client.products.collect { - Product.valueOf(it) - } - } - - private static long decodeCapabilities(final Map request) { - final clientCapabilities = request.client.capabilities as byte[] - long capabilities = 0l - for (int i = 0; i < clientCapabilities.length; i++) { - capabilities |= (clientCapabilities[i] & 0xFFL) << ((clientCapabilities.length - i - 1) * 8) - } - return capabilities - } - - private static boolean hasCapability(final long capabilities, final long test) { - return (capabilities & test) > 0 - } -} diff --git a/dd-smoke-tests/dynamic-config/src/test/groovy/datadog/smoketest/DynamicServiceMappingSmokeTest.groovy b/dd-smoke-tests/dynamic-config/src/test/groovy/datadog/smoketest/DynamicServiceMappingSmokeTest.groovy deleted file mode 100644 index b2898fe7e63..00000000000 --- a/dd-smoke-tests/dynamic-config/src/test/groovy/datadog/smoketest/DynamicServiceMappingSmokeTest.groovy +++ /dev/null @@ -1,45 +0,0 @@ -package datadog.smoketest - -import datadog.smoketest.dynamicconfig.ServiceMappingApplication - -import static java.util.concurrent.TimeUnit.SECONDS - -class DynamicServiceMappingSmokeTest extends AbstractSmokeTest { - // Estimate for the amount of time instrumentation, plus request, plus some extra - public static final int TIMEOUT_SECS = 30 - - @Override - ProcessBuilder createProcessBuilder() { - List command = new ArrayList<>() - command.add(javaPath()) - command.addAll(defaultJavaProperties) - command.add("-Ddd.remote_config.enabled=true") - command.add("-Ddd.remote_config.url=http://localhost:${server.address.port}/v0.7/config".toString()) - command.add("-Ddd.remote_config.poll_interval.seconds=1") - command.addAll((String[]) ["-cp", System.getProperty("datadog.smoketest.shadowJar.path")]) - command.add(ServiceMappingApplication.name) - - ProcessBuilder processBuilder = new ProcessBuilder(command) - processBuilder.directory(new File(buildDirectory)) - } - - def "Updated service mapping observed"() { - when: - def newConfig = """ - { - "lib_config": { - "tracing_service_mapping": [{ - "from_key": "${ServiceMappingApplication.ORIGINAL_SERVICE_NAME}", - "to_name": "${ServiceMappingApplication.MAPPED_SERVICE_NAME}" - }] - } - } - """ as String - - setRemoteConfig("datadog/2/APM_TRACING/config_overrides/config", newConfig) - - then: - assert testedProcess.waitFor(TIMEOUT_SECS, SECONDS) - assert testedProcess.exitValue() == 0 - } -} diff --git a/dd-smoke-tests/dynamic-config/src/test/java/datadog/smoketest/AppSecActivationSmokeTest.java b/dd-smoke-tests/dynamic-config/src/test/java/datadog/smoketest/AppSecActivationSmokeTest.java new file mode 100644 index 00000000000..ec79535fe8c --- /dev/null +++ b/dd-smoke-tests/dynamic-config/src/test/java/datadog/smoketest/AppSecActivationSmokeTest.java @@ -0,0 +1,128 @@ +package datadog.smoketest; + +import static datadog.environment.JavaVirtualMachine.isOracleJDK8; +import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_ACTIVATION; +import static datadog.remoteconfig.Capabilities.CAPABILITY_ASM_CUSTOM_RULES; +import static datadog.remoteconfig.Product.ASM; +import static datadog.remoteconfig.Product.ASM_DATA; +import static datadog.remoteconfig.Product.ASM_DD; +import static java.util.Collections.disjoint; +import static java.util.EnumSet.of; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +import datadog.remoteconfig.Product; +import datadog.smoketest.backend.EnabledIfDockerAvailable; +import datadog.smoketest.backend.RemoteConfig; +import datadog.smoketest.backend.TestAgentBackend; +import datadog.smoketest.backend.TraceBackend; +import datadog.smoketest.dynamicconfig.AppSecApplication; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Verifies AppSec activation via Remote Configuration, ported from the Groovy {@code + * AppSecActivationSmokeTest}. With AppSec enabled but inactive, the tracer advertises only the + * ASM_ACTIVATION capability; once a config activating AppSec is pushed, the tracer reports the + * change via telemetry (DD_APPSEC_ENABLED, origin {@code remote_config}) and then subscribes to the + * ASM rule products (ASM, ASM_DD, ASM_DATA), advertising ASM_CUSTOM_RULES. + * + *

{@link AppSecApplication} just stays alive briefly while its tracer polls Remote Config, so + * the whole activation workflow is asserted from the poll requests and telemetry captured by the + * {@link TestAgentBackend}. Telemetry is central here, so the base's telemetry support is left + * enabled: it flushes the app on a fast heartbeat (so the config-change event arrives within the + * app's short lifetime) and additionally asserts telemetry is flowing. + */ +@EnabledIfDockerAvailable +class AppSecActivationSmokeTest { + + private static final EnumSet ASM_RULE_PRODUCTS = of(ASM, ASM_DD, ASM_DATA); + + // Inline backend owned by the app; held as a field so the test can push and read remote-config. + private static final TestAgentBackend agent = TraceBackend.testAgentBuilder().build(); + + @RegisterExtension + static final SmokeCliApp app = + SmokeCliApp.named("appsec-activation") + .mainClass(AppSecApplication.class.getName()) + .classpath(System.getProperty("datadog.smoketest.shadowJar.path")) + .jvmArgs("-Ddd.remote_config.enabled=true", "-Ddd.remote_config.poll_interval.seconds=1") + .backend(agent) + .build(); + + @Test + void activatesAppSecViaRemoteConfig() { + assumeFalse(isOracleJDK8(), "Telemetry product-change event flakes on Oracle JDK 8"); + + RemoteConfig remoteConfig = agent.remoteConfig(); + + // AppSec is enabled but inactive: a poll that has not subscribed to any ASM rule product yet + // advertises the ASM_ACTIVATION capability, but not ASM_CUSTOM_RULES. + Map beforeActivation = + remoteConfig.waitForRequest( + request -> disjoint(decodeProducts(request), ASM_RULE_PRODUCTS)); + long capabilities = RemoteConfig.capabilities(beforeActivation); + assertTrue(hasCapability(capabilities, CAPABILITY_ASM_ACTIVATION), "ASM_ACTIVATION advertised"); + assertFalse( + hasCapability(capabilities, CAPABILITY_ASM_CUSTOM_RULES), + "ASM_CUSTOM_RULES not advertised while inactive"); + + // Activate AppSec via Remote Config. + remoteConfig.setConfig( + "datadog/2/ASM_FEATURES/asm_features_activation/config", "{\"asm\":{\"enabled\":true}}"); + + // The tracer reports the applied change via a telemetry configuration event. + agent.telemetry().waitForFlat(AppSecActivationSmokeTest::appsecEnabledFromRemoteConfig); + + // Now active: the tracer subscribes to the ASM rule products and advertises ASM_CUSTOM_RULES. + Map afterActivation = + remoteConfig.waitForRequest( + request -> decodeProducts(request).containsAll(ASM_RULE_PRODUCTS)); + assertTrue( + hasCapability(RemoteConfig.capabilities(afterActivation), CAPABILITY_ASM_CUSTOM_RULES), + "ASM_CUSTOM_RULES advertised after activation"); + } + + // A flattened telemetry event whose payload records DD_APPSEC_ENABLED=true from remote config. + @SuppressWarnings("unchecked") + private static boolean appsecEnabledFromRemoteConfig(Map event) { + Object payload = event.get("payload"); + if (!(payload instanceof Map)) { + return false; + } + Object configuration = ((Map) payload).get("configuration"); + if (!(configuration instanceof List)) { + return false; + } + for (Object entry : (List) configuration) { + if (entry instanceof Map) { + Map config = (Map) entry; + if ("DD_APPSEC_ENABLED".equals(config.get("name")) + && "true".equals(String.valueOf(config.get("value"))) + && "remote_config".equals(config.get("origin"))) { + return true; + } + } + } + return false; + } + + // The products a Remote Config poll subscribes to, decoded from the wire strings into typed + // Product values (the tracer serializes them from this same enum). + private static Set decodeProducts(Map request) { + Set products = EnumSet.noneOf(Product.class); + for (String name : RemoteConfig.products(request)) { + products.add(Product.valueOf(name)); + } + return products; + } + + private static boolean hasCapability(long capabilities, long capability) { + return (capabilities & capability) != 0; + } +} diff --git a/dd-smoke-tests/dynamic-config/src/test/java/datadog/smoketest/DynamicServiceMappingSmokeTest.java b/dd-smoke-tests/dynamic-config/src/test/java/datadog/smoketest/DynamicServiceMappingSmokeTest.java new file mode 100644 index 00000000000..2bcd56a4b80 --- /dev/null +++ b/dd-smoke-tests/dynamic-config/src/test/java/datadog/smoketest/DynamicServiceMappingSmokeTest.java @@ -0,0 +1,58 @@ +package datadog.smoketest; + +import static datadog.smoketest.dynamicconfig.ServiceMappingApplication.MAPPED_SERVICE_NAME; +import static datadog.smoketest.dynamicconfig.ServiceMappingApplication.ORIGINAL_SERVICE_NAME; +import static java.util.concurrent.TimeUnit.SECONDS; + +import datadog.smoketest.backend.EnabledIfDockerAvailable; +import datadog.smoketest.backend.TestAgentBackend; +import datadog.smoketest.backend.TraceBackend; +import datadog.smoketest.dynamicconfig.ServiceMappingApplication; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Verifies dynamic service mapping via Remote Configuration: pushes an {@code APM_TRACING} + * service-mapping config to the test agent, and the launched {@link ServiceMappingApplication} + * exits 0 once its tracer applies the mapping ({@value + * ServiceMappingApplication#ORIGINAL_SERVICE_NAME} -> {@value + * ServiceMappingApplication#MAPPED_SERVICE_NAME}) from its {@code /v0.7/config} poll. Ported from + * the Groovy {@code DynamicServiceMappingSmokeTest}. + * + *

The tracer's Remote Config poller shares the agent HTTP client that carries the {@code + * X-Datadog-Test-Session-Token}, so the config pushed to this backend's session reaches this app's + * tracer. Telemetry is not the subject here, so the default telemetry check is skipped. + */ +@EnabledIfDockerAvailable +class DynamicServiceMappingSmokeTest { + + // Inline backend owned by the app; held as a field so the test can push a remote-config payload. + private static final TestAgentBackend agent = TraceBackend.testAgentBuilder().build(); + + @RegisterExtension + static final SmokeCliApp app = + SmokeCliApp.named("dynamic-service-mapping") + .mainClass(ServiceMappingApplication.class.getName()) + .classpath(System.getProperty("datadog.smoketest.shadowJar.path")) + .jvmArgs("-Ddd.remote_config.enabled=true", "-Ddd.remote_config.poll_interval.seconds=1") + .backend(agent) + .skipTelemetryCheck() + .build(); + + @Test + void updatedServiceMappingObserved() { + // Push a service-mapping override; the tracer picks it up on its next /v0.7/config poll and the + // app exits 0 once it observes the remapped service name (or exits 1 after its 10s timeout). + agent + .remoteConfig() + .setConfig( + "datadog/2/APM_TRACING/config_overrides/config", + "{\"lib_config\":{\"tracing_service_mapping\":[{" + + "\"from_key\":\"" + + ORIGINAL_SERVICE_NAME + + "\",\"to_name\":\"" + + MAPPED_SERVICE_NAME + + "\"}]}}"); + app.assertCompletesWithValue(30, SECONDS, 0); + } +} 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..021b022165c --- /dev/null +++ b/dd-smoke-tests/spring-boot-rabbit/src/test/java/datadog/smoketest/SpringBootRabbitSmokeTest.java @@ -0,0 +1,217 @@ +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().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) { + SpanMatcher matcher = span().service(service).operationName(operation).resourceName(resource); + String type = spanType(operation); + if (type != null) { + matcher.type(type); + } + return matcher; + } + + private static String spanType(String operation) { + switch (operation) { + case "servlet.request": + case "spring.handler": + return "web"; + case "amqp.command": + case "amqp.deliver": + case "spring.consume": + return "queue"; + case "amqp.consume": + default: + return null; + } + } + + private static SmokeServerApp.Builder rabbitApp(int index) { + return SmokeServerApp.named("spring-rabbit-" + index) + .jar(System.getProperty("datadog.smoketest.springboot.shadowJar.path")) + .backend(agent) + .jvmArgs( + "-Ddd.service.name=spring-rabbit-" + index, "-Ddd.rabbit.legacy.tracing.enabled=false") + // Resolved at launch, after @Testcontainers has started RABBIT — not at build time. + .placeholder("rabbit.host", RABBIT::getHost) + .placeholder("rabbit.port", () -> String.valueOf(RABBIT.getMappedPort(RABBIT_AMQP_PORT))) + .args( + "--server.port=${app.httpPort}", + "--spring.rabbitmq.host=${rabbit.host}", + "--spring.rabbitmq.port=${rabbit.port}") + // The broker connection is torn down noisily when the app is killed at teardown. + .allowedErrorLogs( + "Failed to check/redeclare auto-delete queue(s)", + "An unexpected connection driver error 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..d858c4fdcca --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/AbstractSmokeApp.java @@ -0,0 +1,691 @@ +package datadog.smoketest; + +import static datadog.trace.test.util.ForkedTestUtils.getMaxMemoryArgumentForFork; +import static datadog.trace.test.util.ForkedTestUtils.getMinMemoryArgumentForFork; +import static java.time.ZoneOffset.UTC; +import static java.util.Arrays.asList; +import static java.util.Locale.ROOT; +import static java.util.concurrent.TimeUnit.SECONDS; +import static java.util.stream.Collectors.joining; + +import datadog.smoketest.backend.TraceBackend; +import datadog.smoketest.backend.Traces; +import datadog.trace.api.internal.VisibleForTesting; +import datadog.trace.test.util.ForkedTestUtils; +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.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +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 java.util.stream.Stream; +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; + +/** + * This class is a base class 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. The extension mechanism will take care of the application + * lifecycle: + * + *
    + *
  • {@link #beforeAll} launches the app (and its owned {@link TraceBackend}) and verify it is + * ready, + *
  • {@link #beforeEach} resets the output logs and the test agent session, + *
  • {@link #afterEach} checks for telemetry reception (only once), + *
  • {@link #afterAll} tears everything down and checks there was no error in logs. + *
+ * + * The current implementations provide two kind of smoke applications: + * + *
    + *
  • {@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(...)}. + *
+ */ +public abstract class AbstractSmokeApp + implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback { + + // 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<>(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 static final DateTimeFormatter LOG_FILE_TIMESTAMP = + DateTimeFormatter.ofPattern("yyyy-MM-dd-HHmmss.SSS", ROOT).withZone(UTC); + + 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 String agentJar; // null => launch without -javaagent + private final long startupTimeoutSeconds; + private final Predicate errorLogFilter; + private final boolean checkErrorLogs; + private final boolean checkTelemetry; + private final boolean applyMemoryTuning; + + 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.agentJar = builder.resolveAgentJar(); + this.startupTimeoutSeconds = builder.startupTimeoutSeconds; + this.checkErrorLogs = builder.checkErrorLogs; + this.checkTelemetry = builder.checkTelemetry; + this.applyMemoryTuning = builder.applyMemoryTuning; + this.errorLogFilter = + builder.errorLogFilter != null + ? builder.errorLogFilter + : defaultErrorLogFilter(builder.allowedErrorLogs); + } + + // --- Handle API (field access) --- + + /** + * Returns the trace query/assert facade of this app's backend. + * + * @return The {@link Traces} facade of this app's backend. + */ + public Traces traces() { + return this.backend.traces(); + } + + /** + * Returns the backend this app sends traces to. + * + * @return The {@link TraceBackend} 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 the given + * predicate. Captured lines are reset per test method. + * + * @param predicate The predicate a captured log line must satisfy. + * @return {@code true} if a matching line was seen before the timeout, {@code false} otherwise. + */ + 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) --- + + /** + * Returns the app's (log/diagnostic) name. + * + * @return The app's (log/diagnostic) name. + */ + protected final String name() { + return this.name; + } + + /** + * Returns the launched process. + * + * @return The launched process, or {@code null} before {@link #beforeAll}. + */ + protected final Process process() { + return this.process; + } + + /** + * Returns how long start-up may wait for the app to become ready. + * + * @return The start-up timeout, in seconds. + */ + protected final long startupTimeoutSeconds() { + return this.startupTimeoutSeconds; + } + + /** + * Registers an additional launch-time placeholder (e.g. a subclass's {@code ${app.httpPort}}). + * + * @param token The literal placeholder token to replace (e.g. {@code ${app.httpPort}}). + * @param value Supplies the replacement value, resolved when the app launches. + */ + 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.backend.isShared()) { + this.backend.close(); + } + } finally { + if (this.checkErrorLogs) { + assertNoErrorLogs(); + } + } + } + } + + /** + * Invoked once right after the app process is launched (in {@link #beforeAll}) for subclasses to + * assert their notion of application readiness. + */ + protected void onStarted() {} + + /** Invoked before each test method (in {@link #beforeEach}), before the captured log is reset. */ + protected void onBeforeEach() {} + + private void launch() throws IOException { + List command = new ArrayList<>(); + command.add(javaExecutable()); + + if (this.applyMemoryTuning) { + command.add(getMaxMemoryArgumentForFork()); + command.add(getMinMemoryArgumentForFork()); + } + + 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 (Entry> entry : this.placeholders.entrySet()) { + String placeholder = entry.getKey(); + if (result.contains(placeholder)) { + result = result.replace(placeholder, entry.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(); + return new File(dir, logFileName(this.name, Instant.now())); + } + + /** + * Builds the per-app log file name, timestamped (UTC) so a retry doesn't clobber the prior run's + * log. + * + * @param name The app's (log/diagnostic) name. + * @param when The instant to stamp into the file name. + * @return The timestamped log file name. + */ + @VisibleForTesting + static String logFileName(String name, Instant when) { + return "smoke-app." + name + "." + LOG_FILE_TIMESTAMP.format(when) + ".log"; + } + + /** + * Asserts the app logged no error lines, per the configured filter. Reads the whole captured log + * (everything since launch). Auto-invoked at teardown unless {@link Builder#skipErrorLogCheck()} + * was set; may also be called explicitly mid-run. + * + * @throws AssertionError If the captured log contains one or more error lines. + */ + public void assertNoErrorLogs() { + if (this.logFile == null) { + return; // never launched / nothing captured + } + try (Stream errorLines = + Files.lines(this.logFile.toPath(), StandardCharsets.UTF_8).filter(this.errorLogFilter)) { + String errors = errorLines.map(s -> "\n " + s).collect(joining()); + if (!errors.isEmpty()) { + throw new AssertionError("App '" + this.name + "' logged error line(s):" + errors); + } + } catch (NoSuchFileException e) { + return; // no output file was produced + } catch (IOException e) { + throw new IllegalStateException("Failed to read app log " + this.logFile, e); + } + } + + /** + * 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(...)}. + * + * @throws AssertionError If no telemetry message arrives before the start-up timeout. + */ + public void assertTelemetryReceived() { + this.backend.telemetry().waitForCount(1, this.startupTimeoutSeconds); + } + + @VisibleForTesting + static Predicate defaultErrorLogFilter(List allowed) { + List allowlist = new ArrayList<>(allowed); + return line -> { + for (String allowedSubstring : allowlist) { + if (line.contains(allowedSubstring)) { + return false; + } + } + // See ProcessManager.isErrorLog() + 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"; + } + + /** + * Fluent builder shared by the concrete apps. Self-typed so every setter returns the concrete + * builder for chaining and {@code build()} returns the concrete app. Obtain one via {@link + * SmokeServerApp#named(String)} or {@link SmokeCliApp#named(String)}. + * + * @param The concrete app type this builder builds. + * @param The concrete builder type, returned from every setter for fluent chaining. + */ + 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; + private boolean applyMemoryTuning = true; + + protected Builder(String name) { + this.name = name; + } + + /** + * Returns {@code this} as the concrete builder type, for fluent chaining. + * + * @return {@code this} as the concrete builder type. + */ + protected abstract B self(); + + /** + * Builds the concrete app. Implementations must call {@link #validate()} first. + * + * @return The built app. + */ + public abstract A build(); + + /** + * Runs {@code java -jar }. Mutually exclusive with {@link #mainClass(String)}. + * + * @param jarPath The path to the runnable jar. + * @return This builder, for chaining. + */ + public B jar(String jarPath) { + this.jar = jarPath; + return self(); + } + + /** + * Runs {@code java -cp } (classpath defaults to the current one). + * + * @param mainClass The fully-qualified main class to run. + * @return This builder, for chaining. + */ + public B mainClass(String mainClass) { + this.mainClass = mainClass; + return self(); + } + + /** + * Sets the classpath for {@link #mainClass(String)} mode; defaults to the launching JVM's + * classpath. + * + * @param classpath The classpath for the launched JVM. + * @return This builder, for chaining. + */ + public B classpath(String classpath) { + this.classpath = classpath; + return self(); + } + + /** + * Adds program arguments (after the jar/main class). Supports launch-time {@code ${...}} + * placeholders (see {@link #placeholder(String, Supplier)}); {@link SmokeServerApp} also + * provides {@code ${app.httpPort}}. + * + * @param args The program arguments to append. + * @return This builder, for chaining. + */ + public B args(String... args) { + this.programArgs.addAll(asList(args)); + return self(); + } + + /** + * Adds extra JVM arguments (before the jar/main class). Supports the same launch-time {@code + * ${...}} placeholders as {@link #args(String...)}. + * + * @param jvmArgs The JVM arguments to append. + * @return This builder, for chaining. + */ + public B jvmArgs(String... jvmArgs) { + this.jvmArgs.addAll(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}")
+     * }
+ * + * @param name The placeholder name; matched as ${name} in args and jvmArgs. + * @param value Supplies the replacement value, resolved when the app launches. + * @return This builder, for chaining. + */ + 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). + * + * @param key The environment variable name. + * @param value The environment variable value. + * @return This builder, for chaining. + */ + public B env(String key, String value) { + this.extraEnv.put(key, value); + return self(); + } + + /** + * Sets the working directory for the launched process. + * + * @param workingDirectory The working directory. + * @return This builder, for chaining. + */ + public B workingDirectory(File workingDirectory) { + this.workingDirectory = workingDirectory; + return self(); + } + + /** + * Sets the backend the app sends traces to. + * + * @param backend The trace backend. + * @return This builder, for chaining. + */ + public B backend(TraceBackend backend) { + this.backend = backend; + return self(); + } + + /** + * Overrides the agent jar (default: the {@code datadog.smoketest.agent.shadowJar.path} + * property). + * + * @param agentJarPath The path to the agent jar. + * @return This builder, for chaining. + */ + public B javaAgent(String agentJarPath) { + this.explicitAgentJar = agentJarPath; + return self(); + } + + /** + * Launches the app without {@code -javaagent} (e.g. for launch-mechanics tests). + * + * @return This builder, for chaining. + */ + public B noAgent() { + this.noAgent = true; + return self(); + } + + /** + * Sets how long start-up waits for the app to become ready (default 120s). + * + * @param seconds The start-up timeout, in seconds. + * @return This builder, for chaining. + */ + public B startupTimeoutSeconds(long seconds) { + this.startupTimeoutSeconds = seconds; + return self(); + } + + /** + * Overrides how a captured log line is judged an error. Replaces the allowlist. + * + * @param isError The predicate reporting whether a captured log line is an error. + * @return This builder, for chaining. + */ + public B errorLogFilter(Predicate isError) { + this.errorLogFilter = isError; + return self(); + } + + /** + * Allowlists log lines containing any of these substrings from the default error-log check. + * + * @param substrings The substrings whose presence exempts a line from the error check. + * @return This builder, for chaining. + */ + public B allowedErrorLogs(String... substrings) { + this.allowedErrorLogs.addAll(asList(substrings)); + return self(); + } + + /** + * Disables the automatic no-error-logs check at teardown (e.g. for error-case tests). + * + * @return This builder, for chaining. + */ + 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}). + * + * @return This builder, for chaining. + */ + public B skipTelemetryCheck() { + this.checkTelemetry = false; + return self(); + } + + /** + * Disables the default child-JVM heap bounds ({@code -Xmx}/{@code -Xms} from {@link + * ForkedTestUtils}). You can instead override the values by passing {@code -Xmx}/{@code -Xms} + * to {@link #jvmArgs(String...)} (those take precedence). + * + * @return This builder, for chaining. + */ + public B skipMemoryTuning() { + this.applyMemoryTuning = 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 deferred: profiling args; crash-tracking args (-XX:OnError=...dd_crash_uploader.sh); + // All reachable today via .jvmArgs() add dedicated 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..f98b82c84d5 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/SmokeCliApp.java @@ -0,0 +1,86 @@ +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("my-application")
+ *     .jar(System.getProperty("datadog.smoketest.shadowJar.path"))
+ *     .backend(TraceBackend.testAgent())
+ *     .build();
+ *
+ * @Test
+ * void runsToCompletion() {
+ *   app.traces().assertTraces(...);
+ *   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. + * + * @param name The application (log/diagnostic) name. + * @return A new builder for a {@link SmokeCliApp}. + */ + public static Builder named(String name) { + return new Builder(name); + } + + /** + * Asserts the app runs to completion within the timeout and exits with the expected value. Pass a + * non-zero expected value for apps expected to fail (e.g. a tool the agent aborts). + * + * @param timeout The maximum time to wait for the app to complete. + * @param unit The time unit of {@code timeout}. + * @param expectedExitValue The exit code the app is expected to return. + * @throws AssertionError If the app does not terminate in time or exits with a different code. + */ + 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); + } + } + + /** 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..21b62fbf056 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/SmokeServerApp.java @@ -0,0 +1,130 @@ +package datadog.smoketest; + +import static datadog.trace.agent.test.utils.PortUtils.waitForPortToOpen; +import static java.util.concurrent.TimeUnit.SECONDS; + +import datadog.smoketest.backend.TraceBackend; +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("my-server-app")
+ *     .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. + * + * @param name The application (log/diagnostic) name. + * @return A new builder for a {@link SmokeServerApp}. + */ + public static Builder named(String name) { + return new Builder(name); + } + + /** + * Returns the randomly-allocated port the app should bind, substituted for {@value + * #HTTP_PORT_PLACEHOLDER} in launch args. + * + * @return The HTTP port the app binds. + */ + public int httpPort() { + return this.httpPort; + } + + /** + * Returns the base URL of the app's HTTP server. + * + * @return The base URL of the app's HTTP server. + */ + public URI url() { + return URI.create("http://localhost:" + this.httpPort); + } + + /** + * Issues a GET to the app (the response is drained and closed). + * + * @param path The request path (a leading {@code /} is added if missing). + * @return The HTTP status code of the response. + * @throws IllegalStateException If the request fails. + */ + @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() { + // Ensure the server is still running + if (process() == null || !process().isAlive()) { + throw new IllegalStateException( + "App '" + name() + "' is no longer alive at the start of a test"); + } + // Clear backend session of owned backend + TraceBackend backend = backend(); + if (!backend.isShared() && backend.clearsBetweenTests()) { + 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/MockAgentBackend.java b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/MockAgentBackend.java new file mode 100644 index 00000000000..8f029a4cf57 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/MockAgentBackend.java @@ -0,0 +1,138 @@ +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) would hang off this + * concrete type rather than the common {@link TraceBackend} facade. + */ +public final class MockAgentBackend extends 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; + + MockAgentBackend() {} + + @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/RemoteConfig.java b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/RemoteConfig.java new file mode 100644 index 00000000000..192e14de926 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/RemoteConfig.java @@ -0,0 +1,130 @@ +package datadog.smoketest.backend; + +import datadog.trace.test.util.PollingConditions; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** + * This class represents the remote configuration capabilities of the {@link TraceBackend}. It + * allows to push a config payload the app's tracer, that it will receive on its next {@code + * /v0.7/config} poll, and read back the tracer's poll requests (to assert the products it + * subscribes to and the capabilities it advertises). + */ +public final class RemoteConfig { + private static final double DEFAULT_TIMEOUT_SECONDS = 10; + + private final BiConsumer setter; + private final Supplier>> requests; + + RemoteConfig(BiConsumer setter, Supplier>> requests) { + this.setter = setter; + this.requests = requests; + } + + /** + * Pushes a Remote Configuration payload the app's tracer will receive on its next {@code + * /v0.7/config} poll. + * + * @param path The RC target path (e.g. {@code datadog/2/APM_TRACING/config_overrides/config}). + * @param config The config content as a JSON object literal (e.g. {@code + * {"asm":{"enabled":true}}}). + */ + public void setConfig(String path, String config) { + this.setter.accept(path, config); + } + + /** + * Returns the tracer's captured Remote Config poll requests (most recent first), each a decoded + * JSON map of the poll body. + * + * @return The captured poll request bodies. + */ + public List> requests() { + return this.requests.get(); + } + + /** + * Waits (up to the default timeout) for a captured Remote Config poll request satisfying the + * given predicate, and returns it. + * + * @param predicate The predicate a poll request must satisfy. + * @return The first matching poll request. + * @throws AssertionError If no matching request arrives before the timeout. + */ + public Map waitForRequest(Predicate> predicate) { + return waitForRequest(predicate, DEFAULT_TIMEOUT_SECONDS); + } + + /** + * As {@link #waitForRequest(Predicate)}, but overriding the timeout for this call. + * + * @param predicate The predicate a poll request must satisfy. + * @param timeoutSeconds How long to wait, in seconds. + * @return The first matching poll request. + * @throws AssertionError If no matching request arrives before the timeout. + */ + public Map waitForRequest( + Predicate> predicate, double timeoutSeconds) { + AtomicReference> match = new AtomicReference<>(); + new PollingConditions(timeoutSeconds) + .eventually( + () -> { + for (Map request : requests()) { + if (predicate.test(request)) { + match.set(request); + return; + } + } + throw new AssertionError("No remote-config poll request matched yet"); + }); + return match.get(); + } + + /** + * Decodes the products a Remote Config poll request subscribes to. + * + * @param request A poll request body (from {@link #requests()} or {@link #waitForRequest}). + * @return The subscribed product names. + */ + @SuppressWarnings("unchecked") + public static Set products(Map request) { + Map client = (Map) request.get("client"); + Set products = new HashSet<>(); + if (client != null && client.get("products") instanceof List) { + for (Object product : (List) client.get("products")) { + products.add(String.valueOf(product)); + } + } + return products; + } + + /** + * Decodes the capability flags a Remote Config poll request advertises. The tracer sends them as + * the big-endian bytes of a {@code long} (trailing zero bytes stripped); this reconstructs that + * {@code long} so callers can test individual {@code Capabilities.CAPABILITY_*} bits. + * + * @param request A poll request body (from {@link #requests()} or {@link #waitForRequest}). + * @return The advertised capabilities, as a bit set packed into a {@code long}. + */ + @SuppressWarnings("unchecked") + public static long capabilities(Map request) { + Map client = (Map) request.get("client"); + if (client == null || !(client.get("capabilities") instanceof List)) { + return 0L; + } + List bytes = (List) client.get("capabilities"); + long capabilities = 0L; + int size = bytes.size(); + for (int i = 0; i < size; i++) { + long value = ((Number) bytes.get(i)).longValue() & 0xFF; + capabilities |= value << ((size - i - 1) * 8); + } + return capabilities; + } +} 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..78f12478b89 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/Telemetry.java @@ -0,0 +1,106 @@ +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; + +/** + * This class is query facade over the app-telemetry messages a {@link TraceBackend} has received. + */ +public final class Telemetry { + private static final double DEFAULT_TIMEOUT_SECONDS = 10; + + private final Supplier>> source; + + Telemetry(Supplier>> source) { + this.source = source; + } + + /** + * Returns the raw telemetry messages received, one map per intake request. + * + * @return The raw telemetry messages. + */ + public List> getMessages() { + return this.source.get(); + } + + /** + * Returns individual telemetry events, expanding each {@code message-batch} into its {@code + * payload} entries. + * + * @return The flattened telemetry events. + */ + @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. + * + * @param count The minimum number of messages to wait for. + */ + public void waitForCount(int count) { + waitForCount(count, DEFAULT_TIMEOUT_SECONDS); + } + + /** + * As {@link #waitForCount(int)}, but overriding the timeout for this call. + * + * @param count The minimum number of messages to wait for. + * @param timeoutSeconds How long to wait, in seconds. + */ + public void 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); + } + }); + } + + /** + * Waits (default timeout) until a flattened telemetry event matches the given predicate. + * + * @param predicate The predicate a flattened telemetry event must satisfy. + */ + public void waitForFlat(Predicate> predicate) { + waitForFlat(predicate, DEFAULT_TIMEOUT_SECONDS); + } + + /** + * As {@link #waitForFlat(Predicate)}, but overriding the timeout for this call. + * + * @param predicate The predicate a flattened telemetry event must satisfy. + * @param timeoutSeconds How long to wait, in seconds. + */ + public void waitForFlat(Predicate> predicate, double timeoutSeconds) { + new PollingConditions(timeoutSeconds) + .eventually( + () -> { + if (getFlatMessages().stream().noneMatch(predicate)) { + throw new AssertionError("No telemetry event matched; received: " + getMessages()); + } + }); + } +} 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..6aecc9e2a9b --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TelemetryDecoder.java @@ -0,0 +1,62 @@ +package datadog.smoketest.backend; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; + +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.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. + * + * @param json The telemetry intake body. + * @return The decoded message map, or an empty map if the body is a JSON {@code null}. + */ + static Map decodeMessage(byte[] json) { + try { + Map message = MESSAGE_ADAPTER.fromJson(new String(json, UTF_8)); + return message == null ? emptyMap() : message; + } catch (IOException | JsonDataException e) { + throw new IllegalStateException("Failed to parse telemetry message", e); + } + } + + /** + * Decodes the test agent's {@code /test/session/apmtelemetry} response (a JSON array of + * messages). + * + * @param json The response body. + * @return The decoded message maps, or an empty list if the body is a JSON {@code null}. + */ + static List> decodeMessages(String json) { + try { + List> messages = MESSAGE_LIST_ADAPTER.fromJson(json); + return messages == null ? 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..405fdc66deb --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TestAgentBackend.java @@ -0,0 +1,451 @@ +package datadog.smoketest.backend; + +import static java.lang.String.join; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Arrays.asList; + +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.DecodedTrace; +import datadog.trace.test.agent.decoder.Decoder; +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +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 (the local-dev default) or an + * already-running external agent ({@link Builder#external(String, int)}, e.g. 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)}, so a test + * body written against the common {@link Traces} surface runs unchanged against this backend or the + * {@link MockAgentBackend}. + * + *

Per-test isolation. 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}, + * {@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 extends TraceBackend { + private static final String DEFAULT_CI_IMAGE = + "registry.ddbuild.io/images/mirror/dd-apm-test-agent/ddapm-test-agent"; + private static final String DEFAULT_PUBLIC_IMAGE = + "ghcr.io/datadog/dd-apm-test-agent/ddapm-test-agent"; + private static final String DEFAULT_VERSION = "v1.44.0"; + private static final String CUSTOM_IMAGE_REF_PROPERTY = "DATADOG_SMOKETEST_TESTAGENT_IMAGE"; + + /** The dd-apm-test-agent trace port inside the container. */ + private static final int AGENT_PORT = 8126; + + /** + * Trace-invariant checks enabled by default, mirroring {@code TracerConnectionReliabilityTest}. + */ + private static final List DEFAULT_ENABLED_CHECKS = + asList("trace_count_header", "meta_tracer_version_header", "trace_content_length"); + + private static final MediaType JSON = MediaType.parse("application/json"); + + private static final Moshi MOSHI = new Moshi.Builder().build(); + private static final JsonAdapter> MAP_ADAPTER = + MOSHI.adapter(Types.newParameterizedType(Map.class, String.class, Object.class)); + private static final JsonAdapter>> REQUEST_LIST_ADAPTER = + MOSHI.adapter( + Types.newParameterizedType( + List.class, Types.newParameterizedType(Map.class, String.class, Object.class))); + + private final String image; + private final String externalHost; // null => Testcontainers-managed container + private final int externalPort; + private final List enabledChecks; + 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.retainAcrossTests = builder.retainAcrossTests; + this.sessionToken = + builder.sessionToken != null ? builder.sessionToken : "smoke-" + UUID.randomUUID(); + } + + static Builder builder() { + return new Builder(); + } + + @Override + public String sessionToken() { + return this.sessionToken; + } + + @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", 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"); + resetRemoteConfig(); + } + + private void resetRemoteConfig() { + // A fresh session does not clear a previously-pushed Remote Config response: it is + // stored under the (stable) session token. Replace it with an empty payload — the + // tracer's "no configs" default — so each test method starts with a clean RC slate, + // matching the per-test trace and telemetry isolation. + HttpUrl url = + requireStarted() + .newBuilder() + .addPathSegments("test/session/responses/config") + .addQueryParameter("test_session_token", this.sessionToken) + .build(); + Request request = new Request.Builder().url(url).post(RequestBody.create(JSON, "{}")).build(); + execute(request, "reset remote-config response"); + } + + @Override + public void close() { + GenericContainer running = this.container; + try { + // Container backends auto-validate their trace-invariant checks at teardown. 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; + // Release the HTTP client's dispatcher threads and pooled connections. + this.client.dispatcher().executorService().shutdown(); + this.client.connectionPool().evictAll(); + } + } + + /** + * Asserts the test agent recorded no trace-invariant check failure ({@code ENABLED_CHECKS}) for + * this backend's session. Auto-invoked at container teardown; may also be called explicitly + * against an external agent mid-test. + * + * @throws AssertionError If the agent recorded one or more trace-invariant check failures. + */ + 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); + } + } + + /** + * Returns this backend's Remote Configuration surface: push a config the app's tracer will + * receive on its next {@code /v0.7/config} poll, and read back the tracer's poll requests. + * + * @return The {@link RemoteConfig} facade for this backend. + */ + public RemoteConfig remoteConfig() { + return new RemoteConfig(this::pushRemoteConfig, this::fetchRemoteConfigRequests); + } + + 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() { + 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 void pushRemoteConfig(String path, String config) { + // POST {"path": ..., "msg": } to /test/session/responses/config/path; the agent builds + // the signed RC envelope from it, so callers don't hand-build it (mirrors the Groovy base's + // setRemoteConfig). + HttpUrl url = + requireStarted() + .newBuilder() + .addPathSegments("test/session/responses/config/path") + .addQueryParameter("test_session_token", this.sessionToken) + .build(); + String body = + "{\"path\":\"" + + path.replace("\\", "\\\\").replace("\"", "\\\"") + + "\",\"msg\":" + + config + + "}"; + Request request = new Request.Builder().url(url).post(RequestBody.create(JSON, body)).build(); + execute(request, "set remote-config response"); + } + + private List> fetchRemoteConfigRequests() { + // The test agent records every request the tracer made in this session; select the /v0.7/config + // polls and decode their base64-encoded bodies into JSON maps. + HttpUrl url = + requireStarted() + .newBuilder() + .addPathSegments("test/session/requests") + .addQueryParameter("test_session_token", this.sessionToken) + .build(); + Request request = new Request.Builder().url(url).get().build(); + String json = execute(request, "read test-agent session requests"); + List> requests; + try { + requests = REQUEST_LIST_ADAPTER.fromJson(json); + } catch (IOException | JsonDataException e) { + throw new IllegalStateException("Failed to parse /test/session/requests: " + json, e); + } + List> polls = new ArrayList<>(); + if (requests != null) { + for (Map req : requests) { + Object rawUrl = req.get("url"); + Object rawBody = req.get("body"); + if (rawUrl instanceof String + && ((String) rawUrl).contains("/v0.7/config") + && rawBody instanceof String + && !((String) rawBody).isEmpty()) { + String decoded = new String(Base64.getDecoder().decode((String) rawBody), UTF_8); + try { + Map poll = MAP_ADAPTER.fromJson(decoded); + if (poll != null) { + polls.add(poll); + } + } catch (IOException | JsonDataException e) { + throw new IllegalStateException( + "Failed to parse remote-config poll body: " + decoded, e); + } + } + } + } + return polls; + } + + 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 {@link TraceBackend#testAgentBuilder()}. */ + public static final class Builder { + private String image; + private String externalHost; + private int externalPort; + private final List enabledChecks; + private boolean retainAcrossTests; + private String sessionToken; + + private Builder() { + String customImageRef = System.getenv(CUSTOM_IMAGE_REF_PROPERTY); + if (customImageRef == null) { + if (System.getenv("CI") != null) { + this.image = DEFAULT_CI_IMAGE + ":" + DEFAULT_VERSION; + } else { + this.image = DEFAULT_PUBLIC_IMAGE + ":" + DEFAULT_VERSION; + } + } else { + this.image = customImageRef; + } + this.externalPort = AGENT_PORT; + this.enabledChecks = new ArrayList<>(DEFAULT_ENABLED_CHECKS); + } + + /** + * Uses a Testcontainers-managed container of the given image. Ignored when {@link + * #external(String, int)} is set. + * + * @param image The dd-apm-test-agent image reference. + * @return This builder, for chaining. + */ + public Builder image(String image) { + this.image = image; + return this; + } + + /** + * Overrides the enabled trace-invariant checks ({@code ENABLED_CHECKS}). + * + * @param checks The check names to enable (replacing the defaults). + * @return This builder, for chaining. + */ + public Builder enabledChecks(String... checks) { + this.enabledChecks.clear(); + this.enabledChecks.addAll(asList(checks)); + return this; + } + + /** + * Talks to an already-running external agent (e.g. the CI sidecar) instead of a container. + * + * @param host The external agent host. + * @param port The external agent port. + * @return This builder, for chaining. + */ + public Builder external(String host, int port) { + this.externalHost = host; + this.externalPort = port; + 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. + * + * @return This builder, for chaining. + */ + public Builder retainAcrossTests() { + this.retainAcrossTests = true; + return this; + } + + /** + * Overrides the auto-generated session token (mainly for deterministic tests). + * + * @param token The session token to use. + * @return This builder, for chaining. + */ + public Builder sessionToken(String token) { + this.sessionToken = token; + return this; + } + + /** + * Builds the configured test-agent backend. + * + * @return The built backend. + */ + 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..aec639c5b0d --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TraceBackend.java @@ -0,0 +1,150 @@ +package datadog.smoketest.backend; + +import static datadog.trace.util.Strings.isNotBlank; + +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; + +/** + * A pluggable trace backend a smoke-test app sends its traces to. Two implementations are provided: + * the in-process {@link #mockAgent() mock agent} and a Dockerized or external {@link #testAgent() + * dd-apm-test-agent}. Both decode received traces into the shared {@link DecodedTrace} model, so a + * test body written against the common {@link Traces} surface runs unchanged on either backend. + * + *

The lifecycle mirrors the JUnit extension that owns the backend: {@link #start()} once per + * test class, {@link #clear()} between methods, {@link #close()} at teardown. + */ +public abstract class TraceBackend + implements AutoCloseable, BeforeAllCallback, BeforeEachCallback, AfterAllCallback { + private volatile boolean registered; + + /** Starts the backend and binds it to a port. Idempotent. */ + public abstract void start(); + + /** + * Returns the agent port the app should send traces to (e.g. {@code -Ddd.trace.agent.port}). + * + * @return The bound agent port. + */ + public abstract int port(); + + /** + * Returns the base URL of the backend. + * + * @return The backend base URL. + */ + public abstract URI url(); + + /** + * Returns the query/assert facade over the traces this backend has received. + * + * @return The {@link Traces} facade for this backend. + */ + public abstract Traces traces(); + + /** + * Returns the query facade over the app-telemetry messages this backend has received. + * + * @return The {@link Telemetry} facade for this backend. + */ + public abstract Telemetry telemetry(); + + /** Discards all traces received so far — call between test methods to isolate them. */ + public abstract void clear(); + + @Override + public abstract void close(); + + /** + * Returns the session token the launched app must emit (via {@code dd.test.agent.session.token}) + * for its traces to be attributed to this backend. The in-process mock owns its own server and + * does not scope by session, so it returns {@code null}; the test agent overrides this. + * + * @return The session token, or {@code null} if the backend does not scope by session. + */ + public String sessionToken() { + return null; + } + + /** + * Returns whether this backend manages its own lifecycle as a separate {@code @RegisterExtension} + * shared across apps. This is inferred from JUnit registration — an inline backend is not a + * registered extension, so it returns {@code false} — and is therefore only accurate once the + * extension lifecycle has begun (from {@link #beforeAll} onward). When {@code false}, the owning + * app starts and stops the backend. + * + * @return {@code true} if the backend is shared and drives its own lifecycle. + */ + public final boolean isShared() { + return this.registered; + } + + /** + * Returns 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. + * + * @return {@code true} to clear between methods, {@code false} to accumulate across them. + */ + public boolean clearsBetweenTests() { + return true; + } + + @Override + public final void beforeAll(ExtensionContext context) { + this.registered = true; + start(); + } + + @Override + public void beforeEach(ExtensionContext context) { + if (clearsBetweenTests()) { + clear(); + } + } + + @Override + public void afterAll(ExtensionContext context) { + close(); + } + + /** + * Creates an in-process mock-agent backend wrapping the testing {@code JavaTestHttpServer}. + * + * @return A new in-process mock-agent backend. + */ + public static MockAgentBackend mockAgent() { + return new MockAgentBackend(); + } + + /** + * Starts a fluent builder for a {@link TestAgentBackend} (dd-apm-test-agent container or + * external). + * + * @return A new test-agent backend builder. + */ + public static TestAgentBackend.Builder testAgentBuilder() { + return TestAgentBackend.builder(); + } + + /** + * Resolves the environment's default test-agent backend: the external CI sidecar when {@code + * CI_AGENT_HOST} is set, otherwise a Testcontainers-managed container (which requires a running + * Docker daemon). + * + * @return An external or containerized test-agent backend. + */ + public static TraceBackend testAgent() { + TestAgentBackend.Builder builder = testAgentBuilder(); + String ciAgentHost = System.getenv("CI_AGENT_HOST"); + if (isNotBlank(ciAgentHost)) { + builder.external(ciAgentHost, 8126); + } + return builder.build(); + } +} 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..04cc8f3a92e --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/Traces.java @@ -0,0 +1,115 @@ +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. + */ +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; + } + + /** + * Returns a snapshot of the traces received so far. + * + * @return 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. Traces arrive asynchronously from the app, so callers wait for the expected count + * before asserting structure. + * + * @param count The minimum number of traces to wait for. + * @return This facade, for chaining. + */ + public Traces waitForTraceCount(int count) { + return waitForTraceCount(count, DEFAULT_TIMEOUT_SECONDS); + } + + /** + * As {@link #waitForTraceCount(int)}, but overriding the timeout for this call. + * + * @param count The minimum number of traces to wait for. + * @param timeoutSeconds How long to wait, in seconds. + * @return This facade, for chaining. + */ + 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: + * + *

{@code
+   * app.traces().assertTraces(
+   *     trace(span().operationName("servlet.request").resourceName("GET /greeting")));
+   * }
+ * + * @param matchers The matchers to verify the received traces, one per expected trace. + */ + 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}). + * + * @param options Configures the trace-collection matching. + * @param matchers The matchers to verify the received traces, one per expected trace. + */ + public void assertTraces( + UnaryOperator options, TraceMatcher... matchers) { + assertTraces(DEFAULT_TIMEOUT_SECONDS, options, matchers); + } + + /** + * As {@link #assertTraces(UnaryOperator, TraceMatcher...)}, overriding the timeout. + * + * @param timeoutSeconds How long to poll for a match, in seconds. + * @param options Configures the trace-collection matching. + * @param matchers The matchers to verify the received traces, one per expected trace. + */ + public void assertTraces( + double timeoutSeconds, + UnaryOperator options, + TraceMatcher... matchers) { + new PollingConditions(timeoutSeconds) + .eventually(() -> SmokeTraceAssertions.assertTraces(getTraces(), options, matchers)); + } + + // Trace-invariant checks (ENABLED_CHECKS) are test-agent-specific — validated by that backend + // itself (TestAgentBackend#assertNoInvariantFailures) rather than on this common, portable + // facade. +} 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..1162fb5e476 --- /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: 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 are + * exercised by the Spring Boot RabbitMQ pilot. + */ +class SharedBackendMultiAppTest { + + @RegisterExtension static final MockAgentBackend agent = TraceBackend.mockAgent(); + + @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 inferred shared from its extension registration"); + 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/SmokeAppLogFileNameTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppLogFileNameTest.java new file mode 100644 index 00000000000..99939fcc43e --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppLogFileNameTest.java @@ -0,0 +1,31 @@ +package datadog.smoketest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * Docker-free unit tests for {@link AbstractSmokeApp#logFileName(String, Instant)}: the per-app log + * file name is timestamped (UTC) so a retry — a fresh JVM re-running the class — doesn't clobber + * the prior run's log. + */ +class SmokeAppLogFileNameTest { + + @Test + void logFileNameIsTimestampedInUtc() { + Instant when = Instant.parse("2026-07-30T12:34:56.789Z"); + assertEquals( + "smoke-app.my-app.2026-07-30-123456.789.log", AbstractSmokeApp.logFileName("my-app", when)); + } + + @Test + void distinctInstantsYieldDistinctNames() { + // Two runs (retries) at different instants must not resolve to the same file. + Instant when = Instant.parse("2026-07-30T12:34:56.789Z"); + assertNotEquals( + AbstractSmokeApp.logFileName("my-app", when), + AbstractSmokeApp.logFileName("my-app", when.plusMillis(1))); + } +} 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..622bafae189 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeServerAppTest.java @@ -0,0 +1,59 @@ +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.assertTrue; + +import datadog.smoketest.backend.TraceBackend; +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, and owned-backend lifecycle. 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"); + } +} 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/MockAgentBackendTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/MockAgentBackendTest.java new file mode 100644 index 00000000000..34a80764b87 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/MockAgentBackendTest.java @@ -0,0 +1,226 @@ +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 and web type, so this holds regardless of the received span order. + backend + .traces() + .waitForTraceCount(1) + .assertTraces( + trace( + span().service("smoke-test-java-app").type("web"), + span().service("smoke-test-java-app").type("web"))); + } + + @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/SmokeServerAppRetainBackendTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/SmokeServerAppRetainBackendTest.java new file mode 100644 index 00000000000..e90a1725587 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/SmokeServerAppRetainBackendTest.java @@ -0,0 +1,81 @@ +package datadog.smoketest.backend; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import datadog.smoketest.SmokeServerApp; +import java.net.URI; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Regression test for an owned {@link SmokeServerApp} backend that retains across tests: {@code + * onBeforeEach} must not clear it, since that would discard the app-startup traces the retention is + * meant to preserve (a shared backend already honors this through its own extension callback; an + * owned one must too). Uses a counting backend + the trivial {@code TestServerApp}; no agent, no + * Docker. + */ +class SmokeServerAppRetainBackendTest { + + // Non-shared (never registered as an extension) backend that retains across tests and counts + // clear() calls; declared before the app so it is initialized when the app builder captures it. + private static final CountingBackend BACKEND = new CountingBackend(); + + @RegisterExtension + static final SmokeServerApp app = + SmokeServerApp.named("retain-server") + .mainClass("datadog.smoketest.TestServerApp") + .args("--server.port=${app.httpPort}") + .backend(BACKEND) + .noAgent() + .build(); + + @Test + void ownedRetainingBackendIsNotClearedBeforeTests() { + // onBeforeEach ran before this test method; because the owned backend retains + // (clearsBetweenTests() == false), the app must not have cleared it. + assertEquals(0, BACKEND.clears.get(), "a retaining owned backend must not be cleared per-test"); + } + + /** An owned (non-shared) backend that retains across tests and counts {@link #clear()} calls. */ + private static final class CountingBackend extends TraceBackend { + final AtomicInteger clears = new AtomicInteger(); + + @Override + public void start() {} + + @Override + public int port() { + return 0; + } + + @Override + public URI url() { + return URI.create("http://localhost:0"); + } + + @Override + public Traces traces() { + return new Traces(Collections::emptyList); + } + + @Override + public Telemetry telemetry() { + return new Telemetry(Collections::emptyList); + } + + @Override + public void clear() { + this.clears.incrementAndGet(); + } + + @Override + public void close() {} + + @Override + public boolean clearsBetweenTests() { + return false; // retain across tests + } + } +} 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..07c0b17e2eb --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendContainerTest.java @@ -0,0 +1,207 @@ +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.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. + * 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}). + */ +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 MediaType JSON = MediaType.parse("application/json"); + 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 clearResetsRemoteConfig() throws IOException { + // Push an RC config, confirm the agent serves it on /v0.7/config, then clear() and confirm the + // session's RC response is reset to the tracer's empty "no configs" default. + String path = "datadog/2/APM_TRACING/config_overrides/config"; + backend.remoteConfig().setConfig(path, "{\"lib_config\":{\"x\":1}}"); + + String served = pollRemoteConfig(backend.url(), backend.sessionToken()); + assertTrue(served.contains(path), "config is served before clear: " + served); + + backend.clear(); + + String afterClear = pollRemoteConfig(backend.url(), backend.sessionToken()); + assertFalse( + afterClear.contains(path), "clear() reset the remote-config response: " + afterClear); + } + + @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 String pollRemoteConfig(URI agentUrl, String token) throws IOException { + // Poll /v0.7/config the way a tracer does; the agent returns the session's stored RC response. + HttpUrl url = HttpUrl.get(agentUrl).newBuilder().addPathSegments("v0.7/config").build(); + Request request = + new Request.Builder() + .url(url) + .header("X-Datadog-Test-Session-Token", token) + .post(RequestBody.create(JSON, "{}")) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertTrue(response.isSuccessful(), "poll /v0.7/config: HTTP " + response.code()); + return response.body().string(); + } + } + + 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..0e355e0e59e --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendTest.java @@ -0,0 +1,235 @@ +package datadog.smoketest.backend; + +import static java.nio.charset.StandardCharsets.UTF_8; +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 java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +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 isSharedInferredFromExtensionRegistration() { + // isShared() is inferred from JUnit invoking the extension's beforeAll callback (i.e. the + // backend was declared as a @RegisterExtension field), not a manual flag. A stub agent lets us + // drive the external lifecycle without Docker; beforeAll(...) is what JUnit calls for a + // registered extension. + try (JavaTestHttpServer agent = stubAgent(200, "")) { + TestAgentBackend backend = + TraceBackend.testAgentBuilder() + .external(agent.getAddress().getHost(), agent.getAddress().getPort()) + .build(); + assertFalse(backend.isShared(), "not shared until registered as an extension"); + backend.beforeAll(null); + try { + assertTrue(backend.isShared(), "inferred shared once its beforeAll callback runs"); + } finally { + backend.close(); + } + } + } + + @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(); + } + } + } + + @Test + void setRemoteConfigPostsPathAndConfigToTheSession() { + // The backend POSTs {"path": ..., "msg": } to /test/session/responses/config/path so + // the agent builds the signed RC envelope; capture that request against a stub agent. + AtomicReference captured = new AtomicReference<>(); + try (JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + server -> + server.handlers( + handlers -> { + handlers.prefix( + "/test/session/responses/config/path", + api -> { + captured.set(new String(api.getRequest().getBody(), UTF_8)); + api.getResponse().status(202).send(); + }); + handlers.all(api -> api.getResponse().status(200).send()); + }))) { + TestAgentBackend backend = + TraceBackend.testAgentBuilder() + .external(agent.getAddress().getHost(), agent.getAddress().getPort()) + .build(); + backend.start(); + try { + backend + .remoteConfig() + .setConfig( + "datadog/2/APM_TRACING/config_overrides/config", "{\"lib_config\":{\"x\":1}}"); + } finally { + backend.close(); + } + String body = captured.get(); + assertNotNull(body, "agent received a config-path POST"); + assertTrue(body.contains("\"path\":\"datadog/2/APM_TRACING/config_overrides/config\""), body); + assertTrue(body.contains("\"msg\":{\"lib_config\":{\"x\":1}}"), body); + } + } + + @Test + void readsAndDecodesRemoteConfigPollRequests() { + // /test/session/requests returns every request the tracer made, each with a base64-encoded + // body. Serve one /v0.7/config poll (and a non-RC request that must be filtered out) and assert + // the backend selects, decodes, and exposes the poll's products and capabilities. + String pollBody = + "{\"client\":{\"products\":[\"APM_TRACING\",\"ASM_FEATURES\"],\"capabilities\":[2]}}"; + String encoded = Base64.getEncoder().encodeToString(pollBody.getBytes(UTF_8)); + String requestsJson = + "[{\"url\":\"http://agent/v0.7/config\",\"method\":\"POST\",\"body\":\"" + + encoded + + "\"},{\"url\":\"http://agent/v0.6/stats\",\"method\":\"POST\",\"body\":\"\"}]"; + try (JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + server -> + server.handlers( + handlers -> { + handlers.prefix( + "/test/session/requests", + api -> api.getResponse().status(200).send(requestsJson)); + handlers.all(api -> api.getResponse().status(200).send()); + }))) { + TestAgentBackend backend = + TraceBackend.testAgentBuilder() + .external(agent.getAddress().getHost(), agent.getAddress().getPort()) + .build(); + backend.start(); + try { + List> polls = backend.remoteConfig().requests(); + assertEquals(1, polls.size(), "only /v0.7/config polls are returned"); + assertTrue( + RemoteConfig.products(polls.get(0)).contains("ASM_FEATURES"), + "products decoded from the poll body"); + assertEquals( + 2L, RemoteConfig.capabilities(polls.get(0)), "capabilities decoded big-endian"); + } finally { + backend.close(); + } + } + } + + @Test + void clearResetsRemoteConfigResponse() { + // clear() opens a fresh session AND resets the session's RC response to empty ({}), so a config + // pushed by one test does not leak into the next. Capture the reset POST against a stub agent. + AtomicReference captured = new AtomicReference<>(); + try (JavaTestHttpServer agent = + JavaTestHttpServer.httpServer( + server -> + server.handlers( + handlers -> { + handlers.prefix( + "/test/session/responses/config", + api -> { + captured.set(new String(api.getRequest().getBody(), UTF_8)); + api.getResponse().status(202).send(); + }); + handlers.all(api -> api.getResponse().status(200).send()); + }))) { + TestAgentBackend backend = + TraceBackend.testAgentBuilder() + .external(agent.getAddress().getHost(), agent.getAddress().getPort()) + .build(); + backend.start(); // start() opens the first session via clear() + try { + assertEquals("{}", captured.get(), "clear() resets the RC response to the empty default"); + } 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/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