diff --git a/dd-smoke-tests/build.gradle b/dd-smoke-tests/build.gradle index 9af814bc880..d8519dad6bd 100644 --- a/dd-smoke-tests/build.gradle +++ b/dd-smoke-tests/build.gradle @@ -8,13 +8,16 @@ 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) + + // Checks the mock backend's remote-config payload with the tracer's own response parser. + testImplementation project(':remote-config:remote-config-core') } tasks.withType(GroovyCompile).configureEach { diff --git a/dd-smoke-tests/gradle.lockfile b/dd-smoke-tests/gradle.lockfile index ecf64db2692..19a687bbd2d 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,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-api:3.4.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.4.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.4.2=compileClasspath,runtimeClasspath,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,21 @@ io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath io.sqreen:libsqreen:17.5.0=runtimeClasspath,testRuntimeClasspath javax.servlet:javax.servlet-api:3.1.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jaxen:jaxen:2.0.6=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,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.10=spotbugs org.apache.ant:ant-antlr:1.10.14=codenarc org.apache.ant:ant-junit:1.10.14=codenarc org.apache.bcel:bcel:6.12.0=spotbugs +org.apache.commons:commons-compress:1.24.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-lang3:3.20.0=spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.logging.log4j:log4j-api:2.26.1=spotbugs org.apache.logging.log4j:log4j-core:2.26.1=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 +78,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,runtimeClasspath,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 @@ -103,16 +109,19 @@ org.ow2.asm:asm-tree:9.7.1=runtimeClasspath,testRuntimeClasspath org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm-util:9.7.1=runtimeClasspath,testRuntimeClasspath org.ow2.asm:asm:9.10.1=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.rnorth.duct-tape:duct-tape:1.0.8=compileClasspath,runtimeClasspath,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.36=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.slf4j:slf4j-api:2.0.17=spotbugsSlf4j org.slf4j:slf4j-api:2.0.18=spotbugs org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.snakeyaml:snakeyaml-engine:2.9=testRuntimeClasspath 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.2=testCompileClasspath,testRuntimeClasspath org.tabletest:tabletest-parser:1.2.1=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:1.21.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=spotbugs empty=annotationProcessor,spotbugsPlugins,testAnnotationProcessor diff --git a/dd-smoke-tests/src/main/groovy/datadog/smoketest/ProcessManager.groovy b/dd-smoke-tests/src/main/groovy/datadog/smoketest/ProcessManager.groovy index fc771e842bd..5cb11cd3216 100644 --- a/dd-smoke-tests/src/main/groovy/datadog/smoketest/ProcessManager.groovy +++ b/dd-smoke-tests/src/main/groovy/datadog/smoketest/ProcessManager.groovy @@ -1,5 +1,7 @@ package datadog.smoketest +import static datadog.smoketest.KnownLogExclusion.isKnownFlakyTestLogEntry + import com.google.common.collect.ImmutableSet import datadog.trace.agent.test.utils.PortUtils import java.nio.file.Files @@ -178,31 +180,10 @@ abstract class ProcessManager extends Specification { * These will be checked on suite shutdown, or explicitly by calling {@link #assertNoErrorLogs()}. */ boolean isErrorLog(String line) { - // FIXME: Flaky profiler exception. See PROF-11068. - if (line.contains('ERROR com.datadog.profiling.controller.ProfilingSystem - Fatal exception in profiling thread, trying to continue')) { - return false - } - - // FIXME: Flaky on Spring Boot, e.g. IastSpringBootSmokeTest :dd-smoke-tests:spring-boot-2.6-webmvc:test semeru8 - if (line.contains("I/O reactor terminated abnormally")) { - return false - } - - // FIXME: Flaky profiler exception. See PROF-11072. - if (line.contains("ERROR com.datadog.profiling.controller.ProfilingSystem - Fatal exception during profiling startup")) { + // Skip known log entries from flaky tests + if (isKnownFlakyTestLogEntry(line)) { return false } - - // FIXME: Observed in semeru8 datadog.smoketest.WildflySmokeTest - if (line.contains("ERROR datadog.trace.agent.jmxfetch.JMXFetch - jmx collector exited with result: 0")) { - return false - } - - // FIXME: Spotted on multiple Spring Boot jobs, e.g. semeru11 IastSpringBootSmokeTest$WithGlobalContext - if (line.contains("I/O reactor terminated abnormally")) { - return false - } - return line.contains("ERROR") || line.contains("ASSERTION FAILED") || line.contains("Failed to handle exception in instrumentation") } 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..508d8d7e8b3 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/AbstractSmokeApp.java @@ -0,0 +1,823 @@ +package datadog.smoketest; + +import static datadog.smoketest.KnownLogExclusion.isKnownFlakyTestLogEntry; +import static datadog.trace.test.util.ForkedTestUtils.getMaxMemoryArgumentForFork; +import static datadog.trace.test.util.ForkedTestUtils.getMinMemoryArgumentForFork; +import static java.nio.charset.StandardCharsets.UTF_8; +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.environment.OperatingSystem; +import datadog.smoketest.backend.AgentBackend; +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.file.Files; +import java.nio.file.NoSuchFileException; +import java.time.Duration; +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.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: + * + *
${name}
{@code + * .placeholder("rabbit.port", () -> String.valueOf(RABBIT.getMappedPort(5672))) + * .args("--spring.rabbitmq.port=${rabbit.port}") + * }
{@code + * @RegisterExtension + * static final SmokeCliApp app = SmokeCliApp.named("my-application") + * .jar(System.getProperty("datadog.smoketest.shadowJar.path")) + * .backend(AgentBackend.testAgent()) + * .build(); + * + * @Test + * void runsToCompletion() { + * app.traces().assertTraces(...); + * app.assertCompletesWithValue(30, SECONDS, 0); + * } + * }
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(AgentBackend.mockAgent()) + * .build(); + * }
{@link #testAgent()} is the default: a real dd-apm-test-agent, either containerized + * or the CI sidecar, which speaks the actual agent protocol and enforces trace invariants. {@link + * #mockAgent()} only approximates it in-process, for rapid local development. + * + *
The lifecycle mirrors the JUnit extension that owns the backend: {@link #start()} once per + * test class, {@link #clear()} between methods, {@link #close()} at teardown. Registering the + * backend as an extension shares it across apps, while handing it to a single app leaves that app + * to drive it (see {@link #isShared()}): + * + *
{@code + * // shared: JUnit drives the lifecycle, every app reports to this one backend + * @RegisterExtension + * static final AgentBackend agent = AgentBackend.testAgent(); + * + * // owned: the app starts, resets and stops its own backend + * @RegisterExtension + * static final SmokeCliApp app = SmokeCliApp.named("my-app") + * .backend(AgentBackend.testAgent()) + * .build(); + * }
Remaining backend-specific capture surfaces (EVP-proxy / DSM) would hang off this concrete + * type rather than the common {@link AgentBackend} facade. + */ +public final class MockAgentBackend extends AgentBackend { + /** + * Agent-info payload advertising the endpoints and capabilities the tracer negotiates from, + * mirroring the real agent. + */ + private static final String INFO_BODY = + "{\"version\":\"7.77.0\"," + + "\"endpoints\":[\"/v0.4/traces\",\"/v0.5/traces\",\"/v1.0/traces\",\"/telemetry/proxy/\"]," + + "\"client_drop_p0s\":true," + + "\"span_meta_structs\":true," + + "\"long_running_spans\":true}"; + + /** JSON mime type. */ + private static final String JSON = "application/json"; + + /** The "no configs" remote-config response, as served until a test pushes one. */ + private static final String NO_REMOTE_CONFIG = "{}"; + + private final List traces = new CopyOnWriteArrayList<>(); + private final List> telemetry = new CopyOnWriteArrayList<>(); + private final List> remoteConfigPolls = new CopyOnWriteArrayList<>(); + private volatile String remoteConfigResponse = NO_REMOTE_CONFIG; + private volatile JavaTestHttpServer server; + // Decoding runs on the server thread, where JavaTestHttpServer swallows the failure into a 500. + private volatile Throwable traceFailure; + private volatile Throwable telemetryFailure; + private volatile Throwable remoteConfigFailure; + + MockAgentBackend() {} + + @Override + public void start() { + if (this.server != null) { + return; + } + this.server = JavaTestHttpServer.httpServer(this::serverSpecs); + } + + private void serverSpecs(JavaTestHttpServer server) { + server.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 -> collectTraces(api, TraceFormat.V1)); + h.prefix("/v0.5/traces", api -> collectTraces(api, TraceFormat.V05)); + h.prefix("/v0.4/traces", api -> collectTraces(api, TraceFormat.V04)); + h.prefix("/v0.7/config", this::serveRemoteConfig); + h.prefix("/telemetry/proxy", this::collectTelemetry); + // Everything else 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 collectTraces(HandlerApi api, TraceFormat format) { + try { + DecodedMessage message = format.decode(api.getRequest().getBody()); + this.traces.addAll(message.getTraces()); + } catch (Throwable t) { + this.traceFailure = t; + throw t; + } + api.getResponse().status(200).sendWithType(JSON, "{}"); + } + + private List collectedTraces() { + if (this.traceFailure != null) { + throw new IllegalStateException( + "Mock agent failed to decode received traces", this.traceFailure); + } + return new ArrayList<>(this.traces); + } + + private void serveRemoteConfig(HandlerApi api) { + byte[] body = api.getRequest().getBody(); + if (body != null && body.length > 0) { + try { + this.remoteConfigPolls.add(AgentBackendMessages.decodeMessage(body)); + } catch (Throwable t) { + this.remoteConfigFailure = t; + throw t; + } + } + api.getResponse().status(200).sendWithType(JSON, this.remoteConfigResponse); + } + + private List> collectedRemoteConfigPolls() { + if (this.remoteConfigFailure != null) { + throw new IllegalStateException( + "Mock agent failed to decode received remote-config polls", this.remoteConfigFailure); + } + return new ArrayList<>(this.remoteConfigPolls); + } + + private void pushRemoteConfig(String path, String config) { + this.remoteConfigResponse = AgentBackendMessages.remoteConfigResponse(path, config); + } + + private void collectTelemetry(HandlerApi api) { + byte[] body = api.getRequest().getBody(); + if (body != null && body.length > 0) { + try { + this.telemetry.add(AgentBackendMessages.decodeMessage(body)); + } catch (Throwable t) { + this.telemetryFailure = t; + throw t; + } + } + api.getResponse().status(202).send(); + } + + private List> collectedTelemetry() { + if (this.telemetryFailure != null) { + throw new IllegalStateException( + "Mock agent failed to decode received telemetry", this.telemetryFailure); + } + return new ArrayList<>(this.telemetry); + } + + @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(this::collectedTraces); + } + + @Override + public Telemetry telemetry() { + return new Telemetry(this::collectedTelemetry); + } + + @Override + public RemoteConfig remoteConfig() { + return new RemoteConfig(this::pushRemoteConfig, this::collectedRemoteConfigPolls); + } + + @Override + public void clear() { + this.traces.clear(); + this.telemetry.clear(); + this.remoteConfigPolls.clear(); + this.remoteConfigResponse = NO_REMOTE_CONFIG; + this.traceFailure = null; + this.telemetryFailure = null; + this.remoteConfigFailure = null; + } + + @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..55a54fb516b --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/RemoteConfig.java @@ -0,0 +1,128 @@ +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 AgentBackend}. 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. + */ +public final class RemoteConfig { + private static final double DEFAULT_TIMEOUT_SECONDS = 30; + + private final BiConsumer setter; + private final Supplier>> requests; + + RemoteConfig(BiConsumer setter, Supplier>> requests) { + this.setter = setter; + this.requests = requests; + } + + /** + * Set a Remote Configuration response payload to the app's tracer 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 {@value #DEFAULT_TIMEOUT_SECONDS}s 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); + } + + /** + * Waits 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, 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..6d9c9529f57 --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/Telemetry.java @@ -0,0 +1,112 @@ +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 AgentBackend} has received. + */ +public final class Telemetry { + private static final double DEFAULT_TIMEOUT_SECONDS = 30; + + 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 {@value DEFAULT_TIMEOUT_SECONDS}s until at least {@code count} messages have been + * received. + * + * @param count The minimum number of messages to wait for. + * @throws AssertionError If no {@code count} message have been received. + */ + public void waitForCount(int count) { + waitForCount(count, DEFAULT_TIMEOUT_SECONDS); + } + + /** + * Waits until at least {@code count} messages have been received. + * + * @param count The minimum number of messages to wait for. + * @param timeoutSeconds How long to wait, in seconds. + * @throws AssertionError If no flattened telemetry event could not match the predicate. + */ + 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 up to {@value #DEFAULT_TIMEOUT_SECONDS}s until a flattened telemetry event matches the + * given predicate. + * + * @param predicate The predicate a flattened telemetry event must satisfy. + * @throws AssertionError If no flattened telemetry event could not match the predicate. + */ + public void waitForFlat(Predicate> predicate) { + waitForFlat(predicate, DEFAULT_TIMEOUT_SECONDS); + } + + /** + * Waits until a flattened telemetry event matches the given predicate. + * + * @param predicate The predicate a flattened telemetry event must satisfy. + * @param timeoutSeconds The timeout delay, in seconds. + * @throws AssertionError If no flattened telemetry event could not match the predicate. + */ + 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/TestAgentBackend.java b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TestAgentBackend.java new file mode 100644 index 00000000000..566f2df6b8b --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/TestAgentBackend.java @@ -0,0 +1,490 @@ +package datadog.smoketest.backend; + +import static datadog.smoketest.backend.AgentBackendMessages.decodeMessages; +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 AgentBackend} backed by a + * dd-apm-test-agent — either an already-running external agent ({@link Builder#external(String, + * int)}, defaulting to the {@code CI_AGENT_HOST} sidecar when that variable is set) or a + * Testcontainers-managed container ({@link Builder#image(String)}, the local-dev default). 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 AgentBackend { + 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.64.1"; + private static final String CUSTOM_IMAGE_REF_PROPERTY = "DATADOG_SMOKETEST_TESTAGENT_IMAGE"; + + /** Set by the CI jobs providing a dd-apm-test-agent sidecar (see {@code .gitlab-ci.yml}). */ + private static final String CI_AGENT_HOST_ENV = "CI_AGENT_HOST"; + + /** The dd-apm-test-agent trace port inside the container. */ + private static final int AGENT_PORT = 8126; + + /** + * Trace-invariant checks enabled by default, mirroring the CI sidecar in {@code .gitlab-ci.yml}. + */ + private static final List DEFAULT_ENABLED_CHECKS = + asList( + "trace_content_length", + "trace_stall", + "meta_tracer_version_header", + "trace_count_header", + "trace_peer_service", + "trace_dd_service"); + + 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(); + } + + /** + * Returns the default dd-apm-test-agent image. + * + * @return The default image reference. + */ + static String defaultImage() { + String image = System.getenv("CI") != null ? DEFAULT_CI_IMAGE : DEFAULT_PUBLIC_IMAGE; + return image + ":" + DEFAULT_VERSION; + } + + @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.externalPort > 0) { + 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.withEnv("TRACE_LANGUAGE", "java"); + started.withEnv("DD_POOL_TRACE_CHECK_FAILURES", "true"); + started.withEnv("DD_DISABLE_ERROR_RESPONSES", "true"); + 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 RemoteConfig remoteConfig() { + return new RemoteConfig(this::pushRemoteConfig, this::fetchRemoteConfigRequests); + } + + @Override + public void clear() { + // Reset before opening the session + resetRemoteConfig(); + // GET /test/session/start begins (and clears) a session identified by the token. The + // dd-apm-test-agent session endpoints are GET (verified against v1.44.0: POST returns 405). + HttpUrl url = + requireStarted() + .newBuilder() + .addPathSegments("test/session/start") + .addQueryParameter("test_session_token", this.sessionToken) + .build(); + Request request = new Request.Builder().url(url).get().build(); + execute(request, "start test-agent session"); + } + + @Override + public void close() { + GenericContainer> running = this.container; + try { + // Container backends auto-validate their trace-invariant checks at teardown. 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); + } + } + + 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 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 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"); + } + + 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 AgentBackend#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); + String ciAgentHost = System.getenv(CI_AGENT_HOST_ENV); + if (customImageRef != null) { + this.image = customImageRef; + } else if (ciAgentHost != null) { + this.externalHost = ciAgentHost; + } else { + this.image = defaultImage(); + } + this.externalPort = AGENT_PORT; + this.enabledChecks = new ArrayList<>(DEFAULT_ENABLED_CHECKS); + } + + /** + * Uses a Testcontainers-managed container of the given image. + * + * @param image The dd-apm-test-agent image reference. + * @return This builder, for chaining. + */ + public Builder image(String image) { + this.image = image; + this.externalHost = null; + this.externalPort = 0; + 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.image = null; + this.externalHost = host; + this.externalPort = port; + return this; + } + + /** + * Keeps received traces across test methods instead of clearing them before each (see {@link + * AgentBackend#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/Traces.java b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/Traces.java new file mode 100644 index 00000000000..57ebcdabdfe --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/Traces.java @@ -0,0 +1,108 @@ +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; + +/** This class is a query facade over the traces a {@link AgentBackend} has received. */ +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 = 30; + + 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 {@value DEFAULT_TIMEOUT_SECONDS}s until at least {@code count} traces have + * been received. + * + * @param count The minimum number of traces to wait for. + * @throws AssertionError If less than {@code count} traces have been received. + */ + public void waitForTraceCount(int count) { + waitForTraceCount(count, DEFAULT_TIMEOUT_SECONDS); + } + + /** + * Waits until at least {@code count} traces have been received. + * + * @param count The minimum number of traces to wait for. + * @param timeoutSeconds How long to wait, in seconds. + * @throws AssertionError If less than {@code count} traces have been received. + */ + public void 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); + } + }); + } + + /** + * Wait up to the {@value #DEFAULT_TIMEOUT_SECONDS}s until the received traces satisfy the given + * trace matchers, one {@link TraceMatcher} per expected trace (matched positionally, + * count-exact). + * + * @param matchers The matchers to verify the received traces, one per expected trace. + * @throws AssertionError If no traces satisfying the given matchers are found. + */ + public void waitForTraces(TraceMatcher... matchers) { + waitForTraces(identity(), matchers); + } + + /** + * Wait up to the {@value #DEFAULT_TIMEOUT_SECONDS}s until the received traces satisfy the given + * trace matchers, one {@link TraceMatcher} per expected tracer, matched according the given + * options as {@link SmokeTraceAssertions#UNORDERED}, {@link + * SmokeTraceAssertions#IGNORE_ADDITIONAL_TRACES}, or {@link + * SmokeTraceAssertions#SORT_BY_START_TIME}). + * + * @param options The options to configure the trace-collection matching. + * @param matchers The matchers to verify the received traces, one per expected trace. + * @throws AssertionError If no traces satisfying the given matchers are found. + */ + public void waitForTraces( + UnaryOperator options, TraceMatcher... matchers) { + waitForTraces(DEFAULT_TIMEOUT_SECONDS, options, matchers); + } + + /** + * Wait up to {@code timeoutSeconds} until the received traces satisfy the given trace matchers, + * one {@link TraceMatcher} per expected tracer, matched according the given options as {@link + * SmokeTraceAssertions#UNORDERED}, {@link SmokeTraceAssertions#IGNORE_ADDITIONAL_TRACES}, or + * {@link SmokeTraceAssertions#SORT_BY_START_TIME}). + * + * @param options The options to configure the trace-collection matching. + * @param matchers The matchers to verify the received traces, one per expected trace. + * @throws AssertionError If no traces satisfying the given matchers are found. + */ + public void waitForTraces( + double timeoutSeconds, + UnaryOperator options, + TraceMatcher... matchers) { + new PollingConditions(timeoutSeconds) + .eventually(() -> SmokeTraceAssertions.assertTraces(getTraces(), options, matchers)); + } +} 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..b68116bf37c --- /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.AgentBackend; +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(AgentBackend.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.waitForLogLine(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..58fd7b96c80 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SharedBackendMultiAppTest.java @@ -0,0 +1,76 @@ +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.AgentBackend; +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 AgentBackend agent = AgentBackend.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..8baa76a9ad5 --- /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.AgentBackend; +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(AgentBackend.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(AgentBackend.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(AgentBackend.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/SmokeAppLogLevelTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppLogLevelTest.java new file mode 100644 index 00000000000..c8099588f65 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppLogLevelTest.java @@ -0,0 +1,46 @@ +package datadog.smoketest; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.smoketest.backend.AgentBackend; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Checks the log level {@link AbstractSmokeApp} launches an app with: {@code info} by default, and + * {@code debug} with {@link AbstractSmokeApp.Builder#debugLogs()}. {@link TestCliApp} echoes the + * level it received, so this asserts the property reaches the child JVM. + */ +class SmokeAppLogLevelTest { + + @RegisterExtension + static final SmokeCliApp defaultLevel = + SmokeCliApp.named("log-level-default") + .mainClass("datadog.smoketest.TestCliApp") + .backend(AgentBackend.mockAgent()) + .noAgent() + .build(); + + @RegisterExtension + static final SmokeCliApp debugLevel = + SmokeCliApp.named("log-level-debug") + .mainClass("datadog.smoketest.TestCliApp") + .backend(AgentBackend.mockAgent()) + .noAgent() + .debugLogs() + .build(); + + @Test + void launchesWithInfoLevelByDefault() { + assertTrue( + defaultLevel.waitForLogLine(line -> line.contains("LOG-LEVEL=info")), + "app launched with the default info log level"); + } + + @Test + void debugLogsRaisesTheChildLogLevel() { + assertTrue( + debugLevel.waitForLogLine(line -> line.contains("LOG-LEVEL=debug")), + "debugLogs() launched the app with the debug log level"); + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeCliAppTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeCliAppTest.java new file mode 100644 index 00000000000..1454ffd2bb5 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeCliAppTest.java @@ -0,0 +1,33 @@ +package datadog.smoketest; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.smoketest.backend.AgentBackend; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Exercises {@link SmokeCliApp}: a one-shot batch app ({@link TestCliApp}) produces all its output + * at start-up, so its captured logs must survive into the test body rather than be cleared per test + * — otherwise a suite asserting start-up diagnostics would flake or always time out. Runs without + * the agent (launch/log-capture mechanics only). + */ +class SmokeCliAppTest { + + @RegisterExtension + static final SmokeCliApp app = + SmokeCliApp.named("test-cli") + .mainClass("datadog.smoketest.TestCliApp") + .backend(AgentBackend.mockAgent()) + .noAgent() + .build(); + + @Test + void retainsStartupLogsForAssertion() { + // The marker was printed during beforeAll; per-test log clearing (which a server does) must not + // apply to a one-shot CLI app, so waitForLogLine still finds it here. + assertTrue( + app.waitForLogLine(line -> line.contains("CLI-STARTUP-MARKER")), + "one-shot start-up output is retained for the test to assert on"); + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeServerAppLivenessTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeServerAppLivenessTest.java new file mode 100644 index 00000000000..19e4bd2bae9 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeServerAppLivenessTest.java @@ -0,0 +1,40 @@ +package datadog.smoketest; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.smoketest.backend.AgentBackend; +import org.junit.jupiter.api.Test; + +/** + * Verifies a {@link SmokeServerApp} whose server dies while a test runs fails that test, including + * during the last (or only) method, after which no later callback re-checks liveness and teardown + * accepts the already-exited process. The app lifecycle is driven by hand rather than through + * {@code @RegisterExtension}, since the expected failure would otherwise fail this very test. + */ +class SmokeServerAppLivenessTest { + + @Test + void failsWhenTheServerDiesDuringATest() throws Exception { + SmokeServerApp app = + SmokeServerApp.named("dying-server") + .mainClass("datadog.smoketest.TestServerApp") + .args("--server.port=${app.httpPort}") + .backend(AgentBackend.mockAgent()) + .noAgent() + .build(); + + app.beforeAll(null); + try { + Process process = app.process(); + process.destroy(); + process.waitFor(); + + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> app.afterEach(null)); + assertTrue(failure.getMessage().contains("at the end of a test"), failure.getMessage()); + } finally { + app.afterAll(null); + } + } +} 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..b4db8092048 --- /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.AgentBackend; +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(AgentBackend.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.waitForLogLine(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.waitForLogLine(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/TestCliApp.java b/dd-smoke-tests/src/test/java/datadog/smoketest/TestCliApp.java new file mode 100644 index 00000000000..25b14f3e601 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/TestCliApp.java @@ -0,0 +1,16 @@ +package datadog.smoketest; + +/** + * Minimal one-shot batch app launched by {@link SmokeCliAppTest} and {@link SmokeAppLogLevelTest}: + * echoes a start-up marker and the log level it was launched with, then exits, exercising {@link + * SmokeCliApp}'s launch/log-capture without the agent. + */ +public final class TestCliApp { + private TestCliApp() {} + + public static void main(String[] args) { + System.out.println("CLI-STARTUP-MARKER"); + System.out.println("LOG-LEVEL=" + System.getProperty("org.slf4j.simpleLogger.defaultLogLevel")); + System.out.flush(); + } +} 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..0b9d06e9c33 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/MockAgentBackendTest.java @@ -0,0 +1,310 @@ +package datadog.smoketest.backend; + +import static datadog.smoketest.trace.SpanMatcher.span; +import static datadog.smoketest.trace.TraceMatcher.trace; +import static java.nio.charset.StandardCharsets.UTF_8; +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 com.squareup.moshi.Moshi; +import datadog.remoteconfig.tuf.InstantJsonAdapter; +import datadog.remoteconfig.tuf.RawJsonAdapter; +import datadog.remoteconfig.tuf.RemoteConfigResponse; +import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedTrace; +import datadog.trace.test.agent.decoder.Decoder; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okio.ByteString; +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 = new MockAgentBackend(); + 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() + .waitForTraces( + 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 infoAdvertisesTraceEndpointsAndCapabilities() 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); + assertTrue(body.contains("\"client_drop_p0s\":true"), 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"); + } + + @Test + void servesPushedRemoteConfigAndCapturesPolls() throws IOException { + String path = "datadog/2/APM_TRACING/config_overrides/config"; + String config = "{\"lib_config\":{\"tracing_sampling_rate\":0.5}}"; + backend.remoteConfig().setConfig(path, config); + + String served = + pollRemoteConfig("{\"client\":{\"products\":[\"APM_TRACING\"],\"capabilities\":[2]}}"); + + // The served payload must satisfy the tracer's own parser, which checks each target file + // against the sha256 and byte length declared in "targets". + RemoteConfigResponse parsed = + new RemoteConfigResponse.Factory( + new Moshi.Builder() + .add(Instant.class, new InstantJsonAdapter()) + .add(ByteString.class, new RawJsonAdapter()) + .build()) + .fromInputStream(new ByteArrayInputStream(served.getBytes(UTF_8))) + .orElseThrow(() -> new AssertionError("remote-config payload not parsed: " + served)); + assertEquals(config, new String(parsed.getFileContents(path), UTF_8), "config content"); + + List> polls = backend.remoteConfig().requests(); + assertEquals(1, polls.size(), "the poll was captured"); + assertTrue(RemoteConfig.products(polls.get(0)).contains("APM_TRACING"), "products decoded"); + assertEquals(2L, RemoteConfig.capabilities(polls.get(0)), "capabilities decoded"); + } + + @Test + void clearResetsRemoteConfig() throws IOException { + backend.remoteConfig().setConfig("datadog/2/APM_TRACING/config_overrides/config", "{\"a\":1}"); + assertTrue(pollRemoteConfig("{}").contains("client_configs"), "config served before clear"); + + backend.clear(); + + assertTrue(backend.remoteConfig().requests().isEmpty(), "captured polls dropped"); + assertEquals("{}", pollRemoteConfig("{}"), "back to the no-configs response"); + } + + private static String pollRemoteConfig(String clientBody) throws IOException { + Request request = + new Request.Builder() + .url(backend.url() + "/v0.7/config") + .post(RequestBody.create(JSON, clientBody)) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertEquals(200, response.code(), "mock agent should serve remote config"); + return response.body().string(); + } + } + + 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()); + } + } + + @Test + void surfacesDecodingFailures() throws IOException { + // Decoding runs on the server thread, where JavaTestHttpServer turns the exception into a 500; + // without capture the test would only see an empty trace collection and time out. + Request request = + new Request.Builder() + .url(backend.url() + "/v0.4/traces") + .put(RequestBody.create(MSGPACK, "not msgpack".getBytes(UTF_8))) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertEquals(500, response.code(), "malformed payload is rejected"); + } + + IllegalStateException error = + assertThrows(IllegalStateException.class, () -> backend.traces().getTraces()); + assertTrue(error.getMessage().contains("decode received traces"), error.getMessage()); + assertNotNull(error.getCause(), "keeps the decoder failure as the cause"); + + // The other surfaces stay usable — the failures are tracked per surface. + assertTrue(backend.telemetry().getMessages().isEmpty(), "telemetry unaffected"); + assertTrue(backend.remoteConfig().requests().isEmpty(), "remote config unaffected"); + + backend.clear(); + assertTrue(backend.traces().getTraces().isEmpty(), "clear() drops the failure too"); + } + + 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..de63aabb219 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/SmokeServerAppRetainBackendTest.java @@ -0,0 +1,86 @@ +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 AgentBackend { + 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 RemoteConfig remoteConfig() { + return new RemoteConfig((path, config) -> {}, 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..2a437bafb71 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendContainerTest.java @@ -0,0 +1,234 @@ +package datadog.smoketest.backend; + +import static datadog.smoketest.backend.TestAgentBackend.defaultImage; +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.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). + */ +class TestAgentBackendContainerTest { + 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 = AgentBackend.testAgentBuilder().image(defaultImage()).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 = + AgentBackend.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 reportsTraceInvariantFailures() throws IOException { + // Its own session token keeps the failure out of the class backend's session, whose teardown + // asserts there are none (the agent only drops pooled failures on an explicit clear). + TestAgentBackend external = + AgentBackend.testAgentBuilder().external(backend.url().getHost(), backend.port()).build(); + external.start(); + try { + // A trace-count header that disagrees with the payload violates trace_count_header. + HttpUrl url = HttpUrl.get(external.url()).newBuilder().addPathSegments("v0.4/traces").build(); + Request request = + new Request.Builder() + .url(url) + .header("X-Datadog-Trace-Count", "5") + .header("Datadog-Meta-Tracer-Version", "0.0.0-smoke-test") + .header("X-Datadog-Test-Session-Token", external.sessionToken()) + .put(RequestBody.create(MSGPACK, v04Payload)) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertTrue(response.isSuccessful(), "submission still succeeds: HTTP " + response.code()); + } + + AssertionError error = + assertThrows(AssertionError.class, external::assertNoInvariantFailures); + assertTrue(error.getMessage().contains("trace_count_header"), error.getMessage()); + } 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..d3b52e4ba76 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendTest.java @@ -0,0 +1,247 @@ +package datadog.smoketest.backend; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Arrays.asList; +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.CopyOnWriteArrayList; +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 = AgentBackend.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", + AgentBackend.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 = + AgentBackend.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 = AgentBackend.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 = + AgentBackend.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 = + AgentBackend.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 = + AgentBackend.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 = + AgentBackend.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 clearResetsRemoteConfigBeforeOpeningTheSession() { + // clear() resets the session's RC response to empty ({}) AND opens a fresh session, so a config + // pushed by one test does not leak into the next. The reset must come first: a tracer poll + // between the two calls would otherwise record the stale config in the new session. + AtomicReference captured = new AtomicReference<>(); + List calls = new CopyOnWriteArrayList<>(); + 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)); + calls.add("reset"); + api.getResponse().status(202).send(); + }); + handlers.prefix( + "/test/session/start", + api -> { + calls.add("start"); + api.getResponse().status(200).send(); + }); + handlers.all(api -> api.getResponse().status(200).send()); + }))) { + TestAgentBackend backend = + AgentBackend.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"); + assertEquals(asList("reset", "start"), calls, "RC reset precedes the session start"); + } finally { + backend.close(); + } + } + } + + /** A stub test agent: 200 on {@code /test/session/start}, {@code failuresStatus} on failures. */ + private static JavaTestHttpServer stubAgent(int failuresStatus, String failuresBody) { + return JavaTestHttpServer.httpServer( + server -> + server.handlers( + handlers -> { + handlers.prefix( + "/test/session/start", api -> api.getResponse().status(200).send()); + handlers.prefix( + "/test/trace_check/failures", + api -> { + if (failuresBody.isEmpty()) { + api.getResponse().status(failuresStatus).send(); + } else { + api.getResponse().status(failuresStatus).send(failuresBody); + } + }); + handlers.all(api -> api.getResponse().status(200).send()); + })); + } +} diff --git a/dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack b/dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack new file mode 100644 index 00000000000..6d4863a3b0f Binary files /dev/null and b/dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack differ
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 AgentBackend { + 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.64.1"; + private static final String CUSTOM_IMAGE_REF_PROPERTY = "DATADOG_SMOKETEST_TESTAGENT_IMAGE"; + + /** Set by the CI jobs providing a dd-apm-test-agent sidecar (see {@code .gitlab-ci.yml}). */ + private static final String CI_AGENT_HOST_ENV = "CI_AGENT_HOST"; + + /** The dd-apm-test-agent trace port inside the container. */ + private static final int AGENT_PORT = 8126; + + /** + * Trace-invariant checks enabled by default, mirroring the CI sidecar in {@code .gitlab-ci.yml}. + */ + private static final List DEFAULT_ENABLED_CHECKS = + asList( + "trace_content_length", + "trace_stall", + "meta_tracer_version_header", + "trace_count_header", + "trace_peer_service", + "trace_dd_service"); + + 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(); + } + + /** + * Returns the default dd-apm-test-agent image. + * + * @return The default image reference. + */ + static String defaultImage() { + String image = System.getenv("CI") != null ? DEFAULT_CI_IMAGE : DEFAULT_PUBLIC_IMAGE; + return image + ":" + DEFAULT_VERSION; + } + + @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.externalPort > 0) { + 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.withEnv("TRACE_LANGUAGE", "java"); + started.withEnv("DD_POOL_TRACE_CHECK_FAILURES", "true"); + started.withEnv("DD_DISABLE_ERROR_RESPONSES", "true"); + 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 RemoteConfig remoteConfig() { + return new RemoteConfig(this::pushRemoteConfig, this::fetchRemoteConfigRequests); + } + + @Override + public void clear() { + // Reset before opening the session + resetRemoteConfig(); + // GET /test/session/start begins (and clears) a session identified by the token. The + // dd-apm-test-agent session endpoints are GET (verified against v1.44.0: POST returns 405). + HttpUrl url = + requireStarted() + .newBuilder() + .addPathSegments("test/session/start") + .addQueryParameter("test_session_token", this.sessionToken) + .build(); + Request request = new Request.Builder().url(url).get().build(); + execute(request, "start test-agent session"); + } + + @Override + public void close() { + GenericContainer> running = this.container; + try { + // Container backends auto-validate their trace-invariant checks at teardown. 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); + } + } + + 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 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 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"); + } + + 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 AgentBackend#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); + String ciAgentHost = System.getenv(CI_AGENT_HOST_ENV); + if (customImageRef != null) { + this.image = customImageRef; + } else if (ciAgentHost != null) { + this.externalHost = ciAgentHost; + } else { + this.image = defaultImage(); + } + this.externalPort = AGENT_PORT; + this.enabledChecks = new ArrayList<>(DEFAULT_ENABLED_CHECKS); + } + + /** + * Uses a Testcontainers-managed container of the given image. + * + * @param image The dd-apm-test-agent image reference. + * @return This builder, for chaining. + */ + public Builder image(String image) { + this.image = image; + this.externalHost = null; + this.externalPort = 0; + 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.image = null; + this.externalHost = host; + this.externalPort = port; + return this; + } + + /** + * Keeps received traces across test methods instead of clearing them before each (see {@link + * AgentBackend#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/Traces.java b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/Traces.java new file mode 100644 index 00000000000..57ebcdabdfe --- /dev/null +++ b/dd-smoke-tests/src/main/java/datadog/smoketest/backend/Traces.java @@ -0,0 +1,108 @@ +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; + +/** This class is a query facade over the traces a {@link AgentBackend} has received. */ +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 = 30; + + 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 {@value DEFAULT_TIMEOUT_SECONDS}s until at least {@code count} traces have + * been received. + * + * @param count The minimum number of traces to wait for. + * @throws AssertionError If less than {@code count} traces have been received. + */ + public void waitForTraceCount(int count) { + waitForTraceCount(count, DEFAULT_TIMEOUT_SECONDS); + } + + /** + * Waits until at least {@code count} traces have been received. + * + * @param count The minimum number of traces to wait for. + * @param timeoutSeconds How long to wait, in seconds. + * @throws AssertionError If less than {@code count} traces have been received. + */ + public void 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); + } + }); + } + + /** + * Wait up to the {@value #DEFAULT_TIMEOUT_SECONDS}s until the received traces satisfy the given + * trace matchers, one {@link TraceMatcher} per expected trace (matched positionally, + * count-exact). + * + * @param matchers The matchers to verify the received traces, one per expected trace. + * @throws AssertionError If no traces satisfying the given matchers are found. + */ + public void waitForTraces(TraceMatcher... matchers) { + waitForTraces(identity(), matchers); + } + + /** + * Wait up to the {@value #DEFAULT_TIMEOUT_SECONDS}s until the received traces satisfy the given + * trace matchers, one {@link TraceMatcher} per expected tracer, matched according the given + * options as {@link SmokeTraceAssertions#UNORDERED}, {@link + * SmokeTraceAssertions#IGNORE_ADDITIONAL_TRACES}, or {@link + * SmokeTraceAssertions#SORT_BY_START_TIME}). + * + * @param options The options to configure the trace-collection matching. + * @param matchers The matchers to verify the received traces, one per expected trace. + * @throws AssertionError If no traces satisfying the given matchers are found. + */ + public void waitForTraces( + UnaryOperator options, TraceMatcher... matchers) { + waitForTraces(DEFAULT_TIMEOUT_SECONDS, options, matchers); + } + + /** + * Wait up to {@code timeoutSeconds} until the received traces satisfy the given trace matchers, + * one {@link TraceMatcher} per expected tracer, matched according the given options as {@link + * SmokeTraceAssertions#UNORDERED}, {@link SmokeTraceAssertions#IGNORE_ADDITIONAL_TRACES}, or + * {@link SmokeTraceAssertions#SORT_BY_START_TIME}). + * + * @param options The options to configure the trace-collection matching. + * @param matchers The matchers to verify the received traces, one per expected trace. + * @throws AssertionError If no traces satisfying the given matchers are found. + */ + public void waitForTraces( + double timeoutSeconds, + UnaryOperator options, + TraceMatcher... matchers) { + new PollingConditions(timeoutSeconds) + .eventually(() -> SmokeTraceAssertions.assertTraces(getTraces(), options, matchers)); + } +} 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..b68116bf37c --- /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.AgentBackend; +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(AgentBackend.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.waitForLogLine(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..58fd7b96c80 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SharedBackendMultiAppTest.java @@ -0,0 +1,76 @@ +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.AgentBackend; +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 AgentBackend agent = AgentBackend.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..8baa76a9ad5 --- /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.AgentBackend; +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(AgentBackend.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(AgentBackend.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(AgentBackend.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/SmokeAppLogLevelTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppLogLevelTest.java new file mode 100644 index 00000000000..c8099588f65 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppLogLevelTest.java @@ -0,0 +1,46 @@ +package datadog.smoketest; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.smoketest.backend.AgentBackend; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Checks the log level {@link AbstractSmokeApp} launches an app with: {@code info} by default, and + * {@code debug} with {@link AbstractSmokeApp.Builder#debugLogs()}. {@link TestCliApp} echoes the + * level it received, so this asserts the property reaches the child JVM. + */ +class SmokeAppLogLevelTest { + + @RegisterExtension + static final SmokeCliApp defaultLevel = + SmokeCliApp.named("log-level-default") + .mainClass("datadog.smoketest.TestCliApp") + .backend(AgentBackend.mockAgent()) + .noAgent() + .build(); + + @RegisterExtension + static final SmokeCliApp debugLevel = + SmokeCliApp.named("log-level-debug") + .mainClass("datadog.smoketest.TestCliApp") + .backend(AgentBackend.mockAgent()) + .noAgent() + .debugLogs() + .build(); + + @Test + void launchesWithInfoLevelByDefault() { + assertTrue( + defaultLevel.waitForLogLine(line -> line.contains("LOG-LEVEL=info")), + "app launched with the default info log level"); + } + + @Test + void debugLogsRaisesTheChildLogLevel() { + assertTrue( + debugLevel.waitForLogLine(line -> line.contains("LOG-LEVEL=debug")), + "debugLogs() launched the app with the debug log level"); + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeCliAppTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeCliAppTest.java new file mode 100644 index 00000000000..1454ffd2bb5 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeCliAppTest.java @@ -0,0 +1,33 @@ +package datadog.smoketest; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.smoketest.backend.AgentBackend; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Exercises {@link SmokeCliApp}: a one-shot batch app ({@link TestCliApp}) produces all its output + * at start-up, so its captured logs must survive into the test body rather than be cleared per test + * — otherwise a suite asserting start-up diagnostics would flake or always time out. Runs without + * the agent (launch/log-capture mechanics only). + */ +class SmokeCliAppTest { + + @RegisterExtension + static final SmokeCliApp app = + SmokeCliApp.named("test-cli") + .mainClass("datadog.smoketest.TestCliApp") + .backend(AgentBackend.mockAgent()) + .noAgent() + .build(); + + @Test + void retainsStartupLogsForAssertion() { + // The marker was printed during beforeAll; per-test log clearing (which a server does) must not + // apply to a one-shot CLI app, so waitForLogLine still finds it here. + assertTrue( + app.waitForLogLine(line -> line.contains("CLI-STARTUP-MARKER")), + "one-shot start-up output is retained for the test to assert on"); + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeServerAppLivenessTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeServerAppLivenessTest.java new file mode 100644 index 00000000000..19e4bd2bae9 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeServerAppLivenessTest.java @@ -0,0 +1,40 @@ +package datadog.smoketest; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.smoketest.backend.AgentBackend; +import org.junit.jupiter.api.Test; + +/** + * Verifies a {@link SmokeServerApp} whose server dies while a test runs fails that test, including + * during the last (or only) method, after which no later callback re-checks liveness and teardown + * accepts the already-exited process. The app lifecycle is driven by hand rather than through + * {@code @RegisterExtension}, since the expected failure would otherwise fail this very test. + */ +class SmokeServerAppLivenessTest { + + @Test + void failsWhenTheServerDiesDuringATest() throws Exception { + SmokeServerApp app = + SmokeServerApp.named("dying-server") + .mainClass("datadog.smoketest.TestServerApp") + .args("--server.port=${app.httpPort}") + .backend(AgentBackend.mockAgent()) + .noAgent() + .build(); + + app.beforeAll(null); + try { + Process process = app.process(); + process.destroy(); + process.waitFor(); + + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> app.afterEach(null)); + assertTrue(failure.getMessage().contains("at the end of a test"), failure.getMessage()); + } finally { + app.afterAll(null); + } + } +} 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..b4db8092048 --- /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.AgentBackend; +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(AgentBackend.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.waitForLogLine(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.waitForLogLine(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/TestCliApp.java b/dd-smoke-tests/src/test/java/datadog/smoketest/TestCliApp.java new file mode 100644 index 00000000000..25b14f3e601 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/TestCliApp.java @@ -0,0 +1,16 @@ +package datadog.smoketest; + +/** + * Minimal one-shot batch app launched by {@link SmokeCliAppTest} and {@link SmokeAppLogLevelTest}: + * echoes a start-up marker and the log level it was launched with, then exits, exercising {@link + * SmokeCliApp}'s launch/log-capture without the agent. + */ +public final class TestCliApp { + private TestCliApp() {} + + public static void main(String[] args) { + System.out.println("CLI-STARTUP-MARKER"); + System.out.println("LOG-LEVEL=" + System.getProperty("org.slf4j.simpleLogger.defaultLogLevel")); + System.out.flush(); + } +} 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..0b9d06e9c33 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/MockAgentBackendTest.java @@ -0,0 +1,310 @@ +package datadog.smoketest.backend; + +import static datadog.smoketest.trace.SpanMatcher.span; +import static datadog.smoketest.trace.TraceMatcher.trace; +import static java.nio.charset.StandardCharsets.UTF_8; +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 com.squareup.moshi.Moshi; +import datadog.remoteconfig.tuf.InstantJsonAdapter; +import datadog.remoteconfig.tuf.RawJsonAdapter; +import datadog.remoteconfig.tuf.RemoteConfigResponse; +import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedTrace; +import datadog.trace.test.agent.decoder.Decoder; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okio.ByteString; +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 = new MockAgentBackend(); + 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() + .waitForTraces( + 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 infoAdvertisesTraceEndpointsAndCapabilities() 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); + assertTrue(body.contains("\"client_drop_p0s\":true"), 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"); + } + + @Test + void servesPushedRemoteConfigAndCapturesPolls() throws IOException { + String path = "datadog/2/APM_TRACING/config_overrides/config"; + String config = "{\"lib_config\":{\"tracing_sampling_rate\":0.5}}"; + backend.remoteConfig().setConfig(path, config); + + String served = + pollRemoteConfig("{\"client\":{\"products\":[\"APM_TRACING\"],\"capabilities\":[2]}}"); + + // The served payload must satisfy the tracer's own parser, which checks each target file + // against the sha256 and byte length declared in "targets". + RemoteConfigResponse parsed = + new RemoteConfigResponse.Factory( + new Moshi.Builder() + .add(Instant.class, new InstantJsonAdapter()) + .add(ByteString.class, new RawJsonAdapter()) + .build()) + .fromInputStream(new ByteArrayInputStream(served.getBytes(UTF_8))) + .orElseThrow(() -> new AssertionError("remote-config payload not parsed: " + served)); + assertEquals(config, new String(parsed.getFileContents(path), UTF_8), "config content"); + + List> polls = backend.remoteConfig().requests(); + assertEquals(1, polls.size(), "the poll was captured"); + assertTrue(RemoteConfig.products(polls.get(0)).contains("APM_TRACING"), "products decoded"); + assertEquals(2L, RemoteConfig.capabilities(polls.get(0)), "capabilities decoded"); + } + + @Test + void clearResetsRemoteConfig() throws IOException { + backend.remoteConfig().setConfig("datadog/2/APM_TRACING/config_overrides/config", "{\"a\":1}"); + assertTrue(pollRemoteConfig("{}").contains("client_configs"), "config served before clear"); + + backend.clear(); + + assertTrue(backend.remoteConfig().requests().isEmpty(), "captured polls dropped"); + assertEquals("{}", pollRemoteConfig("{}"), "back to the no-configs response"); + } + + private static String pollRemoteConfig(String clientBody) throws IOException { + Request request = + new Request.Builder() + .url(backend.url() + "/v0.7/config") + .post(RequestBody.create(JSON, clientBody)) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertEquals(200, response.code(), "mock agent should serve remote config"); + return response.body().string(); + } + } + + 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()); + } + } + + @Test + void surfacesDecodingFailures() throws IOException { + // Decoding runs on the server thread, where JavaTestHttpServer turns the exception into a 500; + // without capture the test would only see an empty trace collection and time out. + Request request = + new Request.Builder() + .url(backend.url() + "/v0.4/traces") + .put(RequestBody.create(MSGPACK, "not msgpack".getBytes(UTF_8))) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertEquals(500, response.code(), "malformed payload is rejected"); + } + + IllegalStateException error = + assertThrows(IllegalStateException.class, () -> backend.traces().getTraces()); + assertTrue(error.getMessage().contains("decode received traces"), error.getMessage()); + assertNotNull(error.getCause(), "keeps the decoder failure as the cause"); + + // The other surfaces stay usable — the failures are tracked per surface. + assertTrue(backend.telemetry().getMessages().isEmpty(), "telemetry unaffected"); + assertTrue(backend.remoteConfig().requests().isEmpty(), "remote config unaffected"); + + backend.clear(); + assertTrue(backend.traces().getTraces().isEmpty(), "clear() drops the failure too"); + } + + 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..de63aabb219 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/SmokeServerAppRetainBackendTest.java @@ -0,0 +1,86 @@ +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 AgentBackend { + 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 RemoteConfig remoteConfig() { + return new RemoteConfig((path, config) -> {}, 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..2a437bafb71 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendContainerTest.java @@ -0,0 +1,234 @@ +package datadog.smoketest.backend; + +import static datadog.smoketest.backend.TestAgentBackend.defaultImage; +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.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). + */ +class TestAgentBackendContainerTest { + 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 = AgentBackend.testAgentBuilder().image(defaultImage()).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 = + AgentBackend.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 reportsTraceInvariantFailures() throws IOException { + // Its own session token keeps the failure out of the class backend's session, whose teardown + // asserts there are none (the agent only drops pooled failures on an explicit clear). + TestAgentBackend external = + AgentBackend.testAgentBuilder().external(backend.url().getHost(), backend.port()).build(); + external.start(); + try { + // A trace-count header that disagrees with the payload violates trace_count_header. + HttpUrl url = HttpUrl.get(external.url()).newBuilder().addPathSegments("v0.4/traces").build(); + Request request = + new Request.Builder() + .url(url) + .header("X-Datadog-Trace-Count", "5") + .header("Datadog-Meta-Tracer-Version", "0.0.0-smoke-test") + .header("X-Datadog-Test-Session-Token", external.sessionToken()) + .put(RequestBody.create(MSGPACK, v04Payload)) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertTrue(response.isSuccessful(), "submission still succeeds: HTTP " + response.code()); + } + + AssertionError error = + assertThrows(AssertionError.class, external::assertNoInvariantFailures); + assertTrue(error.getMessage().contains("trace_count_header"), error.getMessage()); + } 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..d3b52e4ba76 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendTest.java @@ -0,0 +1,247 @@ +package datadog.smoketest.backend; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Arrays.asList; +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.CopyOnWriteArrayList; +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 = AgentBackend.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", + AgentBackend.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 = + AgentBackend.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 = AgentBackend.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 = + AgentBackend.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 = + AgentBackend.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 = + AgentBackend.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 = + AgentBackend.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 clearResetsRemoteConfigBeforeOpeningTheSession() { + // clear() resets the session's RC response to empty ({}) AND opens a fresh session, so a config + // pushed by one test does not leak into the next. The reset must come first: a tracer poll + // between the two calls would otherwise record the stale config in the new session. + AtomicReference captured = new AtomicReference<>(); + List calls = new CopyOnWriteArrayList<>(); + 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)); + calls.add("reset"); + api.getResponse().status(202).send(); + }); + handlers.prefix( + "/test/session/start", + api -> { + calls.add("start"); + api.getResponse().status(200).send(); + }); + handlers.all(api -> api.getResponse().status(200).send()); + }))) { + TestAgentBackend backend = + AgentBackend.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"); + assertEquals(asList("reset", "start"), calls, "RC reset precedes the session start"); + } finally { + backend.close(); + } + } + } + + /** A stub test agent: 200 on {@code /test/session/start}, {@code failuresStatus} on failures. */ + private static JavaTestHttpServer stubAgent(int failuresStatus, String failuresBody) { + return JavaTestHttpServer.httpServer( + server -> + server.handlers( + handlers -> { + handlers.prefix( + "/test/session/start", api -> api.getResponse().status(200).send()); + handlers.prefix( + "/test/trace_check/failures", + api -> { + if (failuresBody.isEmpty()) { + api.getResponse().status(failuresStatus).send(); + } else { + api.getResponse().status(failuresStatus).send(failuresBody); + } + }); + handlers.all(api -> api.getResponse().status(200).send()); + })); + } +} diff --git a/dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack b/dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack new file mode 100644 index 00000000000..6d4863a3b0f Binary files /dev/null and b/dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack differ
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 AgentBackend agent = AgentBackend.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..8baa76a9ad5 --- /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.AgentBackend; +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(AgentBackend.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(AgentBackend.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(AgentBackend.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/SmokeAppLogLevelTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppLogLevelTest.java new file mode 100644 index 00000000000..c8099588f65 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeAppLogLevelTest.java @@ -0,0 +1,46 @@ +package datadog.smoketest; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.smoketest.backend.AgentBackend; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Checks the log level {@link AbstractSmokeApp} launches an app with: {@code info} by default, and + * {@code debug} with {@link AbstractSmokeApp.Builder#debugLogs()}. {@link TestCliApp} echoes the + * level it received, so this asserts the property reaches the child JVM. + */ +class SmokeAppLogLevelTest { + + @RegisterExtension + static final SmokeCliApp defaultLevel = + SmokeCliApp.named("log-level-default") + .mainClass("datadog.smoketest.TestCliApp") + .backend(AgentBackend.mockAgent()) + .noAgent() + .build(); + + @RegisterExtension + static final SmokeCliApp debugLevel = + SmokeCliApp.named("log-level-debug") + .mainClass("datadog.smoketest.TestCliApp") + .backend(AgentBackend.mockAgent()) + .noAgent() + .debugLogs() + .build(); + + @Test + void launchesWithInfoLevelByDefault() { + assertTrue( + defaultLevel.waitForLogLine(line -> line.contains("LOG-LEVEL=info")), + "app launched with the default info log level"); + } + + @Test + void debugLogsRaisesTheChildLogLevel() { + assertTrue( + debugLevel.waitForLogLine(line -> line.contains("LOG-LEVEL=debug")), + "debugLogs() launched the app with the debug log level"); + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeCliAppTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeCliAppTest.java new file mode 100644 index 00000000000..1454ffd2bb5 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeCliAppTest.java @@ -0,0 +1,33 @@ +package datadog.smoketest; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.smoketest.backend.AgentBackend; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Exercises {@link SmokeCliApp}: a one-shot batch app ({@link TestCliApp}) produces all its output + * at start-up, so its captured logs must survive into the test body rather than be cleared per test + * — otherwise a suite asserting start-up diagnostics would flake or always time out. Runs without + * the agent (launch/log-capture mechanics only). + */ +class SmokeCliAppTest { + + @RegisterExtension + static final SmokeCliApp app = + SmokeCliApp.named("test-cli") + .mainClass("datadog.smoketest.TestCliApp") + .backend(AgentBackend.mockAgent()) + .noAgent() + .build(); + + @Test + void retainsStartupLogsForAssertion() { + // The marker was printed during beforeAll; per-test log clearing (which a server does) must not + // apply to a one-shot CLI app, so waitForLogLine still finds it here. + assertTrue( + app.waitForLogLine(line -> line.contains("CLI-STARTUP-MARKER")), + "one-shot start-up output is retained for the test to assert on"); + } +} diff --git a/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeServerAppLivenessTest.java b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeServerAppLivenessTest.java new file mode 100644 index 00000000000..19e4bd2bae9 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/SmokeServerAppLivenessTest.java @@ -0,0 +1,40 @@ +package datadog.smoketest; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import datadog.smoketest.backend.AgentBackend; +import org.junit.jupiter.api.Test; + +/** + * Verifies a {@link SmokeServerApp} whose server dies while a test runs fails that test, including + * during the last (or only) method, after which no later callback re-checks liveness and teardown + * accepts the already-exited process. The app lifecycle is driven by hand rather than through + * {@code @RegisterExtension}, since the expected failure would otherwise fail this very test. + */ +class SmokeServerAppLivenessTest { + + @Test + void failsWhenTheServerDiesDuringATest() throws Exception { + SmokeServerApp app = + SmokeServerApp.named("dying-server") + .mainClass("datadog.smoketest.TestServerApp") + .args("--server.port=${app.httpPort}") + .backend(AgentBackend.mockAgent()) + .noAgent() + .build(); + + app.beforeAll(null); + try { + Process process = app.process(); + process.destroy(); + process.waitFor(); + + IllegalStateException failure = + assertThrows(IllegalStateException.class, () -> app.afterEach(null)); + assertTrue(failure.getMessage().contains("at the end of a test"), failure.getMessage()); + } finally { + app.afterAll(null); + } + } +} 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..b4db8092048 --- /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.AgentBackend; +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(AgentBackend.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.waitForLogLine(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.waitForLogLine(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/TestCliApp.java b/dd-smoke-tests/src/test/java/datadog/smoketest/TestCliApp.java new file mode 100644 index 00000000000..25b14f3e601 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/TestCliApp.java @@ -0,0 +1,16 @@ +package datadog.smoketest; + +/** + * Minimal one-shot batch app launched by {@link SmokeCliAppTest} and {@link SmokeAppLogLevelTest}: + * echoes a start-up marker and the log level it was launched with, then exits, exercising {@link + * SmokeCliApp}'s launch/log-capture without the agent. + */ +public final class TestCliApp { + private TestCliApp() {} + + public static void main(String[] args) { + System.out.println("CLI-STARTUP-MARKER"); + System.out.println("LOG-LEVEL=" + System.getProperty("org.slf4j.simpleLogger.defaultLogLevel")); + System.out.flush(); + } +} 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..0b9d06e9c33 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/MockAgentBackendTest.java @@ -0,0 +1,310 @@ +package datadog.smoketest.backend; + +import static datadog.smoketest.trace.SpanMatcher.span; +import static datadog.smoketest.trace.TraceMatcher.trace; +import static java.nio.charset.StandardCharsets.UTF_8; +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 com.squareup.moshi.Moshi; +import datadog.remoteconfig.tuf.InstantJsonAdapter; +import datadog.remoteconfig.tuf.RawJsonAdapter; +import datadog.remoteconfig.tuf.RemoteConfigResponse; +import datadog.trace.test.agent.decoder.DecodedSpan; +import datadog.trace.test.agent.decoder.DecodedTrace; +import datadog.trace.test.agent.decoder.Decoder; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okio.ByteString; +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 = new MockAgentBackend(); + 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() + .waitForTraces( + 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 infoAdvertisesTraceEndpointsAndCapabilities() 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); + assertTrue(body.contains("\"client_drop_p0s\":true"), 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"); + } + + @Test + void servesPushedRemoteConfigAndCapturesPolls() throws IOException { + String path = "datadog/2/APM_TRACING/config_overrides/config"; + String config = "{\"lib_config\":{\"tracing_sampling_rate\":0.5}}"; + backend.remoteConfig().setConfig(path, config); + + String served = + pollRemoteConfig("{\"client\":{\"products\":[\"APM_TRACING\"],\"capabilities\":[2]}}"); + + // The served payload must satisfy the tracer's own parser, which checks each target file + // against the sha256 and byte length declared in "targets". + RemoteConfigResponse parsed = + new RemoteConfigResponse.Factory( + new Moshi.Builder() + .add(Instant.class, new InstantJsonAdapter()) + .add(ByteString.class, new RawJsonAdapter()) + .build()) + .fromInputStream(new ByteArrayInputStream(served.getBytes(UTF_8))) + .orElseThrow(() -> new AssertionError("remote-config payload not parsed: " + served)); + assertEquals(config, new String(parsed.getFileContents(path), UTF_8), "config content"); + + List> polls = backend.remoteConfig().requests(); + assertEquals(1, polls.size(), "the poll was captured"); + assertTrue(RemoteConfig.products(polls.get(0)).contains("APM_TRACING"), "products decoded"); + assertEquals(2L, RemoteConfig.capabilities(polls.get(0)), "capabilities decoded"); + } + + @Test + void clearResetsRemoteConfig() throws IOException { + backend.remoteConfig().setConfig("datadog/2/APM_TRACING/config_overrides/config", "{\"a\":1}"); + assertTrue(pollRemoteConfig("{}").contains("client_configs"), "config served before clear"); + + backend.clear(); + + assertTrue(backend.remoteConfig().requests().isEmpty(), "captured polls dropped"); + assertEquals("{}", pollRemoteConfig("{}"), "back to the no-configs response"); + } + + private static String pollRemoteConfig(String clientBody) throws IOException { + Request request = + new Request.Builder() + .url(backend.url() + "/v0.7/config") + .post(RequestBody.create(JSON, clientBody)) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertEquals(200, response.code(), "mock agent should serve remote config"); + return response.body().string(); + } + } + + 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()); + } + } + + @Test + void surfacesDecodingFailures() throws IOException { + // Decoding runs on the server thread, where JavaTestHttpServer turns the exception into a 500; + // without capture the test would only see an empty trace collection and time out. + Request request = + new Request.Builder() + .url(backend.url() + "/v0.4/traces") + .put(RequestBody.create(MSGPACK, "not msgpack".getBytes(UTF_8))) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertEquals(500, response.code(), "malformed payload is rejected"); + } + + IllegalStateException error = + assertThrows(IllegalStateException.class, () -> backend.traces().getTraces()); + assertTrue(error.getMessage().contains("decode received traces"), error.getMessage()); + assertNotNull(error.getCause(), "keeps the decoder failure as the cause"); + + // The other surfaces stay usable — the failures are tracked per surface. + assertTrue(backend.telemetry().getMessages().isEmpty(), "telemetry unaffected"); + assertTrue(backend.remoteConfig().requests().isEmpty(), "remote config unaffected"); + + backend.clear(); + assertTrue(backend.traces().getTraces().isEmpty(), "clear() drops the failure too"); + } + + 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..de63aabb219 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/SmokeServerAppRetainBackendTest.java @@ -0,0 +1,86 @@ +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 AgentBackend { + 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 RemoteConfig remoteConfig() { + return new RemoteConfig((path, config) -> {}, 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..2a437bafb71 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendContainerTest.java @@ -0,0 +1,234 @@ +package datadog.smoketest.backend; + +import static datadog.smoketest.backend.TestAgentBackend.defaultImage; +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.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). + */ +class TestAgentBackendContainerTest { + 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 = AgentBackend.testAgentBuilder().image(defaultImage()).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 = + AgentBackend.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 reportsTraceInvariantFailures() throws IOException { + // Its own session token keeps the failure out of the class backend's session, whose teardown + // asserts there are none (the agent only drops pooled failures on an explicit clear). + TestAgentBackend external = + AgentBackend.testAgentBuilder().external(backend.url().getHost(), backend.port()).build(); + external.start(); + try { + // A trace-count header that disagrees with the payload violates trace_count_header. + HttpUrl url = HttpUrl.get(external.url()).newBuilder().addPathSegments("v0.4/traces").build(); + Request request = + new Request.Builder() + .url(url) + .header("X-Datadog-Trace-Count", "5") + .header("Datadog-Meta-Tracer-Version", "0.0.0-smoke-test") + .header("X-Datadog-Test-Session-Token", external.sessionToken()) + .put(RequestBody.create(MSGPACK, v04Payload)) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertTrue(response.isSuccessful(), "submission still succeeds: HTTP " + response.code()); + } + + AssertionError error = + assertThrows(AssertionError.class, external::assertNoInvariantFailures); + assertTrue(error.getMessage().contains("trace_count_header"), error.getMessage()); + } 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..d3b52e4ba76 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendTest.java @@ -0,0 +1,247 @@ +package datadog.smoketest.backend; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Arrays.asList; +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.CopyOnWriteArrayList; +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 = AgentBackend.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", + AgentBackend.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 = + AgentBackend.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 = AgentBackend.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 = + AgentBackend.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 = + AgentBackend.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 = + AgentBackend.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 = + AgentBackend.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 clearResetsRemoteConfigBeforeOpeningTheSession() { + // clear() resets the session's RC response to empty ({}) AND opens a fresh session, so a config + // pushed by one test does not leak into the next. The reset must come first: a tracer poll + // between the two calls would otherwise record the stale config in the new session. + AtomicReference captured = new AtomicReference<>(); + List calls = new CopyOnWriteArrayList<>(); + 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)); + calls.add("reset"); + api.getResponse().status(202).send(); + }); + handlers.prefix( + "/test/session/start", + api -> { + calls.add("start"); + api.getResponse().status(200).send(); + }); + handlers.all(api -> api.getResponse().status(200).send()); + }))) { + TestAgentBackend backend = + AgentBackend.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"); + assertEquals(asList("reset", "start"), calls, "RC reset precedes the session start"); + } finally { + backend.close(); + } + } + } + + /** A stub test agent: 200 on {@code /test/session/start}, {@code failuresStatus} on failures. */ + private static JavaTestHttpServer stubAgent(int failuresStatus, String failuresBody) { + return JavaTestHttpServer.httpServer( + server -> + server.handlers( + handlers -> { + handlers.prefix( + "/test/session/start", api -> api.getResponse().status(200).send()); + handlers.prefix( + "/test/trace_check/failures", + api -> { + if (failuresBody.isEmpty()) { + api.getResponse().status(failuresStatus).send(); + } else { + api.getResponse().status(failuresStatus).send(failuresBody); + } + }); + handlers.all(api -> api.getResponse().status(200).send()); + })); + } +} diff --git a/dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack b/dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack new file mode 100644 index 00000000000..6d4863a3b0f Binary files /dev/null and b/dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack differ
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 = new MockAgentBackend(); + 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() + .waitForTraces( + 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 infoAdvertisesTraceEndpointsAndCapabilities() 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); + assertTrue(body.contains("\"client_drop_p0s\":true"), 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"); + } + + @Test + void servesPushedRemoteConfigAndCapturesPolls() throws IOException { + String path = "datadog/2/APM_TRACING/config_overrides/config"; + String config = "{\"lib_config\":{\"tracing_sampling_rate\":0.5}}"; + backend.remoteConfig().setConfig(path, config); + + String served = + pollRemoteConfig("{\"client\":{\"products\":[\"APM_TRACING\"],\"capabilities\":[2]}}"); + + // The served payload must satisfy the tracer's own parser, which checks each target file + // against the sha256 and byte length declared in "targets". + RemoteConfigResponse parsed = + new RemoteConfigResponse.Factory( + new Moshi.Builder() + .add(Instant.class, new InstantJsonAdapter()) + .add(ByteString.class, new RawJsonAdapter()) + .build()) + .fromInputStream(new ByteArrayInputStream(served.getBytes(UTF_8))) + .orElseThrow(() -> new AssertionError("remote-config payload not parsed: " + served)); + assertEquals(config, new String(parsed.getFileContents(path), UTF_8), "config content"); + + List> polls = backend.remoteConfig().requests(); + assertEquals(1, polls.size(), "the poll was captured"); + assertTrue(RemoteConfig.products(polls.get(0)).contains("APM_TRACING"), "products decoded"); + assertEquals(2L, RemoteConfig.capabilities(polls.get(0)), "capabilities decoded"); + } + + @Test + void clearResetsRemoteConfig() throws IOException { + backend.remoteConfig().setConfig("datadog/2/APM_TRACING/config_overrides/config", "{\"a\":1}"); + assertTrue(pollRemoteConfig("{}").contains("client_configs"), "config served before clear"); + + backend.clear(); + + assertTrue(backend.remoteConfig().requests().isEmpty(), "captured polls dropped"); + assertEquals("{}", pollRemoteConfig("{}"), "back to the no-configs response"); + } + + private static String pollRemoteConfig(String clientBody) throws IOException { + Request request = + new Request.Builder() + .url(backend.url() + "/v0.7/config") + .post(RequestBody.create(JSON, clientBody)) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertEquals(200, response.code(), "mock agent should serve remote config"); + return response.body().string(); + } + } + + 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()); + } + } + + @Test + void surfacesDecodingFailures() throws IOException { + // Decoding runs on the server thread, where JavaTestHttpServer turns the exception into a 500; + // without capture the test would only see an empty trace collection and time out. + Request request = + new Request.Builder() + .url(backend.url() + "/v0.4/traces") + .put(RequestBody.create(MSGPACK, "not msgpack".getBytes(UTF_8))) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertEquals(500, response.code(), "malformed payload is rejected"); + } + + IllegalStateException error = + assertThrows(IllegalStateException.class, () -> backend.traces().getTraces()); + assertTrue(error.getMessage().contains("decode received traces"), error.getMessage()); + assertNotNull(error.getCause(), "keeps the decoder failure as the cause"); + + // The other surfaces stay usable — the failures are tracked per surface. + assertTrue(backend.telemetry().getMessages().isEmpty(), "telemetry unaffected"); + assertTrue(backend.remoteConfig().requests().isEmpty(), "remote config unaffected"); + + backend.clear(); + assertTrue(backend.traces().getTraces().isEmpty(), "clear() drops the failure too"); + } + + 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..de63aabb219 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/SmokeServerAppRetainBackendTest.java @@ -0,0 +1,86 @@ +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 AgentBackend { + 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 RemoteConfig remoteConfig() { + return new RemoteConfig((path, config) -> {}, 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..2a437bafb71 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendContainerTest.java @@ -0,0 +1,234 @@ +package datadog.smoketest.backend; + +import static datadog.smoketest.backend.TestAgentBackend.defaultImage; +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.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). + */ +class TestAgentBackendContainerTest { + 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 = AgentBackend.testAgentBuilder().image(defaultImage()).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 = + AgentBackend.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 reportsTraceInvariantFailures() throws IOException { + // Its own session token keeps the failure out of the class backend's session, whose teardown + // asserts there are none (the agent only drops pooled failures on an explicit clear). + TestAgentBackend external = + AgentBackend.testAgentBuilder().external(backend.url().getHost(), backend.port()).build(); + external.start(); + try { + // A trace-count header that disagrees with the payload violates trace_count_header. + HttpUrl url = HttpUrl.get(external.url()).newBuilder().addPathSegments("v0.4/traces").build(); + Request request = + new Request.Builder() + .url(url) + .header("X-Datadog-Trace-Count", "5") + .header("Datadog-Meta-Tracer-Version", "0.0.0-smoke-test") + .header("X-Datadog-Test-Session-Token", external.sessionToken()) + .put(RequestBody.create(MSGPACK, v04Payload)) + .build(); + try (Response response = CLIENT.newCall(request).execute()) { + assertTrue(response.isSuccessful(), "submission still succeeds: HTTP " + response.code()); + } + + AssertionError error = + assertThrows(AssertionError.class, external::assertNoInvariantFailures); + assertTrue(error.getMessage().contains("trace_count_header"), error.getMessage()); + } 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..d3b52e4ba76 --- /dev/null +++ b/dd-smoke-tests/src/test/java/datadog/smoketest/backend/TestAgentBackendTest.java @@ -0,0 +1,247 @@ +package datadog.smoketest.backend; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Arrays.asList; +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.CopyOnWriteArrayList; +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 = AgentBackend.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", + AgentBackend.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 = + AgentBackend.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 = AgentBackend.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 = + AgentBackend.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 = + AgentBackend.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 = + AgentBackend.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 = + AgentBackend.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 clearResetsRemoteConfigBeforeOpeningTheSession() { + // clear() resets the session's RC response to empty ({}) AND opens a fresh session, so a config + // pushed by one test does not leak into the next. The reset must come first: a tracer poll + // between the two calls would otherwise record the stale config in the new session. + AtomicReference captured = new AtomicReference<>(); + List calls = new CopyOnWriteArrayList<>(); + 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)); + calls.add("reset"); + api.getResponse().status(202).send(); + }); + handlers.prefix( + "/test/session/start", + api -> { + calls.add("start"); + api.getResponse().status(200).send(); + }); + handlers.all(api -> api.getResponse().status(200).send()); + }))) { + TestAgentBackend backend = + AgentBackend.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"); + assertEquals(asList("reset", "start"), calls, "RC reset precedes the session start"); + } finally { + backend.close(); + } + } + } + + /** A stub test agent: 200 on {@code /test/session/start}, {@code failuresStatus} on failures. */ + private static JavaTestHttpServer stubAgent(int failuresStatus, String failuresBody) { + return JavaTestHttpServer.httpServer( + server -> + server.handlers( + handlers -> { + handlers.prefix( + "/test/session/start", api -> api.getResponse().status(200).send()); + handlers.prefix( + "/test/trace_check/failures", + api -> { + if (failuresBody.isEmpty()) { + api.getResponse().status(failuresStatus).send(); + } else { + api.getResponse().status(failuresStatus).send(failuresBody); + } + }); + handlers.all(api -> api.getResponse().status(200).send()); + })); + } +} diff --git a/dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack b/dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack new file mode 100644 index 00000000000..6d4863a3b0f Binary files /dev/null and b/dd-smoke-tests/src/test/resources/datadog/smoketest/backend/webflux.04.msgpack differ