From b6b0809cded0b5c91bb0d96e4a24b9b9743bae14 Mon Sep 17 00:00:00 2001 From: Bruce Bujon Date: Tue, 28 Jul 2026 11:04:56 +0200 Subject: [PATCH] feat(testing): Add smoke test JUnit framework --- dd-smoke-tests/build.gradle | 2 +- dd-smoke-tests/gradle.lockfile | 20 +- .../datadog/smoketest/AbstractSmokeApp.java | 725 ++++++++++++++++++ .../java/datadog/smoketest/SmokeCliApp.java | 86 +++ .../datadog/smoketest/SmokeServerApp.java | 130 ++++ .../smoketest/backend/MockAgentBackend.java | 138 ++++ .../smoketest/backend/RemoteConfig.java | 130 ++++ .../datadog/smoketest/backend/Telemetry.java | 106 +++ .../smoketest/backend/TelemetryDecoder.java | 62 ++ .../smoketest/backend/TestAgentBackend.java | 469 +++++++++++ .../smoketest/backend/TraceBackend.java | 150 ++++ .../datadog/smoketest/backend/Traces.java | 115 +++ .../datadog/smoketest/ErrorLogSmokeTest.java | 39 + .../smoketest/SharedBackendMultiAppTest.java | 77 ++ .../SmokeAppBuilderValidationTest.java | 57 ++ .../smoketest/SmokeAppErrorLogFilterTest.java | 66 ++ .../smoketest/SmokeAppLogFileNameTest.java | 31 + .../datadog/smoketest/SmokeServerAppTest.java | 59 ++ .../java/datadog/smoketest/TestServerApp.java | 54 ++ .../backend/MockAgentBackendTest.java | 226 ++++++ .../SmokeServerAppRetainBackendTest.java | 81 ++ .../TestAgentBackendContainerTest.java | 207 +++++ .../backend/TestAgentBackendTest.java | 235 ++++++ .../smoketest/backend/webflux.04.msgpack | Bin 0 -> 1044 bytes 24 files changed, 3259 insertions(+), 6 deletions(-) create mode 100644 dd-smoke-tests/src/main/java/datadog/smoketest/AbstractSmokeApp.java create mode 100644 dd-smoke-tests/src/main/java/datadog/smoketest/SmokeCliApp.java create mode 100644 dd-smoke-tests/src/main/java/datadog/smoketest/SmokeServerApp.java create mode 100644 dd-smoke-tests/src/main/java/datadog/smoketest/backend/MockAgentBackend.java create mode 100644 dd-smoke-tests/src/main/java/datadog/smoketest/backend/RemoteConfig.java create mode 100644 dd-smoke-tests/src/main/java/datadog/smoketest/backend/Telemetry.java create mode 100644 dd-smoke-tests/src/main/java/datadog/smoketest/backend/TelemetryDecoder.java create mode 100644 dd-smoke-tests/src/main/java/datadog/smoketest/backend/TestAgentBackend.java create mode 100644 dd-smoke-tests/src/main/java/datadog/smoketest/backend/TraceBackend.java create mode 100644 dd-smoke-tests/src/main/java/datadog/smoketest/backend/Traces.java create mode 100644 dd-smoke-tests/src/test/java/datadog/smoketest/ErrorLogSmokeTest.java create mode 100644 dd-smoke-tests/src/test/java/datadog/smoketest/SharedBackendMultiAppTest.java create mode 100644 dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppBuilderValidationTest.java create mode 100644 dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppErrorLogFilterTest.java create mode 100644 dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppLogFileNameTest.java create mode 100644 dd-smoke-tests/src/test/java/datadog/smoketest/SmokeServerAppTest.java create mode 100644 dd-smoke-tests/src/test/java/datadog/smoketest/TestServerApp.java create mode 100644 dd-smoke-tests/src/test/java/datadog/smoketest/backend/MockAgentBackendTest.java create mode 100644 dd-smoke-tests/src/test/java/datadog/smoketest/backend/SmokeServerAppRetainBackendTest.java create mode 100644 dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendContainerTest.java create mode 100644 dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendTest.java create mode 100644 dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack 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/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/src/main/java/datadog/smoketest/AbstractSmokeApp.java b/dd-smoke-tests/src/main/java/datadog/smoketest/AbstractSmokeApp.java new file mode 100644 index 00000000000..44efef52984 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/AbstractSmokeApp.java @@ -0,0 +1,725 @@ +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: + * + * + * + * The current implementations provide two kind of smoke applications: + * + * + */ +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); + + // Repository-wide known-flaky log lines excluded from the default error-log check. + private static final List DEFAULT_ERROR_LOG_EXCLUSIONS = + asList( + // FIXME: Flaky profiler exception. See PROF-11068. + "ERROR com.datadog.profiling.controller.ProfilingSystem - Fatal exception in profiling" + + " thread, trying to continue", + // FIXME: Flaky profiler exception. See PROF-11072. + "ERROR com.datadog.profiling.controller.ProfilingSystem - Fatal exception during" + + " profiling startup", + // FIXME: Flaky on Spring Boot (e.g. IastSpringBootSmokeTest) and other HTTP-client + // suites. + "I/O reactor terminated abnormally", + // FIXME: Observed in WildflySmokeTest (semeru8): a successful JMX collector exit. + "ERROR datadog.trace.agent.jmxfetch.JMXFetch - jmx collector exited with result: 0"); + + 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(); + if (!this.process.waitFor(10, SECONDS)) { + throw new IllegalStateException( + "App '" + + name() + + "' did not terminate after destroy. A lingering process may retain" + + " its port and files and interfere with later tests"); + } + } + } 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; + } + } + for (String knownNoise : DEFAULT_ERROR_LOG_EXCLUSIONS) { + if (line.contains(knownNoise)) { + 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. Use backend(...) to build your app"); + } + if ((this.jar == null) == (this.mainClass == null)) { + throw new IllegalStateException("Exactly one of jar(...) or mainClass(...) must be set"); + } + if (!this.noAgent && resolveAgentJar() == null) { + throw new IllegalStateException( + "Agent jar not found: system property '" + + AGENT_JAR_PROPERTY + + "' is not set. Gradle sets it automatically; on other runners call javaAgent(path)" + + " to point at an agent jar, or noAgent() to run without the tracer."); + } + // 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..0e5dbac535c --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TestAgentBackend.java @@ -0,0 +1,469 @@ +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; + // Clean external base URI derived from baseUrl once at start() (see cleanBaseUri). + private volatile URI baseUri; + + 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(); + } + // Normalize the external URI + this.baseUri = cleanBaseUri(this.baseUrl); + // Open a fresh session so the very first test method starts clean. + clear(); + } + + @Override + public int port() { + return requireStarted().port(); + } + + @Override + public URI url() { + requireStarted(); + return this.baseUri; + } + + @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; + this.baseUri = 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); + } + } + + /** + * Removes leading bracket and supports IPv6 address. + * + * @param url The OkHttp URL to convert to URI. + * @return The converted URI. + */ + private static URI cleanBaseUri(HttpUrl url) { + String host = url.host(); + if (host.indexOf(':') >= 0 && host.charAt(0) != '[') { + host = "[" + host + "]"; + } + return URI.create(url.scheme() + "://" + host + ":" + url.port()); + } + + 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..2cf416f8a5c --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SharedBackendMultiAppTest.java @@ -0,0 +1,77 @@ +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.Order; +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; {@code @Order} makes the backend tear down after the apps (JUnit + * runs teardown in reverse registration order, and {@code @RegisterExtension} field order is not + * otherwise guaranteed), so the backend stays up while the child JVMs shut down. + * + *

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 { + + @Order(1) + @RegisterExtension + static final MockAgentBackend agent = TraceBackend.mockAgent(); + + @Order(2) + @RegisterExtension + static final SmokeServerApp producer = + SmokeServerApp.named("producer") + .mainClass("datadog.smoketest.TestServerApp") + .args("--server.port=${app.httpPort}") + .backend(agent) + .noAgent() + .build(); + + @Order(3) + @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/SmokeAppBuilderValidationTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppBuilderValidationTest.java new file mode 100644 index 00000000000..02f130e5076 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppBuilderValidationTest.java @@ -0,0 +1,57 @@ +package datadog.smoketest; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +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; + +/** Docker-free unit tests for {@link AbstractSmokeApp}'s builder validation. */ +class SmokeAppBuilderValidationTest { + private static final String AGENT_JAR_PROPERTY = "datadog.smoketest.agent.shadowJar.path"; + + @Test + void buildRequiresAnAgentJarUnlessNoAgentOrExplicitAgent() { + String saved = System.getProperty(AGENT_JAR_PROPERTY); + System.clearProperty(AGENT_JAR_PROPERTY); + try { + // No noAgent()/javaAgent() and no agent-jar property: fail loudly rather than silently launch + // without the tracer (which would also skip the telemetry check and pass app-only + // assertions). + IllegalStateException error = + assertThrows( + IllegalStateException.class, + () -> + SmokeServerApp.named("no-agent-jar") + .mainClass("datadog.smoketest.TestServerApp") + .backend(TraceBackend.mockAgent()) + .build()); + assertTrue(error.getMessage().contains("Agent jar not found"), error.getMessage()); + + // noAgent() is a valid way to build without an agent jar. + assertDoesNotThrow( + () -> + SmokeServerApp.named("no-agent") + .mainClass("datadog.smoketest.TestServerApp") + .backend(TraceBackend.mockAgent()) + .noAgent() + .build(), + "noAgent() should build without an agent jar"); + + // An explicit javaAgent(path) is also valid without the property. + assertDoesNotThrow( + () -> + SmokeServerApp.named("explicit-agent") + .mainClass("datadog.smoketest.TestServerApp") + .backend(TraceBackend.mockAgent()) + .javaAgent("/tmp/some-agent.jar") + .build(), + "an explicit agent jar should build without the property"); + } finally { + if (saved != null) { + System.setProperty(AGENT_JAR_PROPERTY, saved); + } + } + } +} 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..6c78d32b804 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppErrorLogFilterTest.java @@ -0,0 +1,66 @@ +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"); + } + + @Test + void excludesRepositoryWideKnownFlakyLines() { + // The default predicate carries the same known-flaky exclusions as ProcessManager.isErrorLog(), + // so migrated profiling/Spring/WildFly suites don't have to re-add them (and don't flake). + Predicate isError = AbstractSmokeApp.defaultErrorLogFilter(emptyList()); + + assertFalse( + isError.test( + "[dd-profiler] ERROR com.datadog.profiling.controller.ProfilingSystem - Fatal exception" + + " in profiling thread, trying to continue"), + "PROF-11068 profiling-thread exception"); + assertFalse( + isError.test( + "ERROR com.datadog.profiling.controller.ProfilingSystem - Fatal exception during" + + " profiling startup"), + "PROF-11072 profiling-startup exception"); + assertFalse( + isError.test("org.apache.http ... I/O reactor terminated abnormally"), + "recoverable I/O reactor termination"); + assertFalse( + isError.test( + "ERROR datadog.trace.agent.jmxfetch.JMXFetch - jmx collector exited with result: 0"), + "successful JMX collector exit"); + + // A genuine profiling/agent ERROR that is NOT in the exclusion list is still flagged. + assertTrue( + isError.test( + "ERROR datadog.trace.agent.jmxfetch.JMXFetch - jmx collector exited with" + + " result: 1"), + "a real (non-zero) JMX collector failure is still an error"); + } +} 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 0000000000000000000000000000000000000000..6d4863a3b0f648a57e5a126e9541bf6fda59d6a1 GIT binary patch literal 1044 zcma)*J#5r46vtaeCZGa@)S(DQk|x*VKDtz>f(|eO1qLD~es^)3*skrQ6d?vAJ_dvY zVrJml-lZQqVl7LBXps;S5i=u#r8{<7?yiCg;w5@7ejoqeKhIx0ZA%a-2_PSmF`ETT zfmGHEN|A*)U!*A3nQ8DR8C8Ns5ePekC}JG}k|o02`&(8C6o5zY_Hb=t=he5PV{OS1 zH34|IefsNV{wCu{097VLHa6a!BVTGuQX!$XPi)@a`f>l`_YO`3QiRd1!;_!IqFUh4 zng@RggkWNz9fJ}ikPp^8jO`eZOa$P;Ooajvwh2D7sJ}puSN8(NCdYIxP)t~%Qrx11 zS!10D(Po00(3(l(EMPHbRL_;4l3Wnlv~2x!@fhgiu-O=T+vDII`tkE@0XY-u!_Jce>9hQ#DZ=f9OQnIv-kybJJ`$AN z6qT+iJazppo^8r9GAVt#q(UiiHSI%b8oM+KxW%deOktGd9h-)q8$iDj5Iau$AJX zV2Og|-62*)LKTU@A~-(_PQ!P=FIz6uYgQR5$f}i5+44~ZJK)yq4Zpftl+gc5$Z$(r F<`-)4vxNWv literal 0 HcmV?d00001