From 5f837f1a95d3390ef4548ec28e4eb9e6999a7a20 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 29 Aug 2025 12:43:33 -0400 Subject: [PATCH 1/2] Initial smoke tests for tracer flare functionality Squashed 9 commits --- dd-java-agent/agent-profiling/build.gradle | 3 +- .../datadog/smoketest/ProcessManager.groovy | 2 +- dd-smoke-tests/tracer-flare/build.gradle | 18 ++ .../datadog/smoketest/flare/SimpleApp.java | 15 ++ .../smoketest/TracerFlareSmokeTest.groovy | 196 ++++++++++++++++++ settings.gradle.kts | 1 + 6 files changed, 233 insertions(+), 2 deletions(-) create mode 100644 dd-smoke-tests/tracer-flare/build.gradle create mode 100644 dd-smoke-tests/tracer-flare/src/main/java/datadog/smoketest/flare/SimpleApp.java create mode 100644 dd-smoke-tests/tracer-flare/src/test/groovy/datadog/smoketest/TracerFlareSmokeTest.groovy diff --git a/dd-java-agent/agent-profiling/build.gradle b/dd-java-agent/agent-profiling/build.gradle index 0504d15f5d1..737ba5c446a 100644 --- a/dd-java-agent/agent-profiling/build.gradle +++ b/dd-java-agent/agent-profiling/build.gradle @@ -10,7 +10,8 @@ excludedClassesCoverage += [ 'com.datadog.profiling.agent.CompositeController.CompositeOngoingRecording', 'com.datadog.profiling.agent.ProfilingAgent', 'com.datadog.profiling.agent.ProfilingAgent.ShutdownHook', - 'com.datadog.profiling.agent.ProfilingAgent.DataDumper' + 'com.datadog.profiling.agent.ProfilingAgent.DataDumper', + 'com.datadog.profiling.agent.ProfilerFlare' ] dependencies { 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 9d7fe392fd7..64effc049ea 100644 --- a/dd-smoke-tests/src/main/groovy/datadog/smoketest/ProcessManager.groovy +++ b/dd-smoke-tests/src/main/groovy/datadog/smoketest/ProcessManager.groovy @@ -60,7 +60,7 @@ abstract class ProcessManager extends Specification { def setup() { testedProcesses.each { - assert it.alive: "Process $it is not availble on test beginning" + assert it.alive: "Process $it is not available on test beginning" } synchronized (outputThreads.testLogMessages) { diff --git a/dd-smoke-tests/tracer-flare/build.gradle b/dd-smoke-tests/tracer-flare/build.gradle new file mode 100644 index 00000000000..136156adc98 --- /dev/null +++ b/dd-smoke-tests/tracer-flare/build.gradle @@ -0,0 +1,18 @@ +apply from: "$rootDir/gradle/java.gradle" +description = 'Tracer Flare Smoke Tests.' + +jar { + manifest { + attributes('Main-Class': 'datadog.smoketest.flare.SimpleApp') + } +} + +dependencies { + testImplementation project(':dd-smoke-tests') +} + +tasks.withType(Test).configureEach { + dependsOn "jar" + + jvmArgs "-Ddatadog.smoketest.tracer-flare.jar.path=${tasks.jar.archiveFile.get()}" +} diff --git a/dd-smoke-tests/tracer-flare/src/main/java/datadog/smoketest/flare/SimpleApp.java b/dd-smoke-tests/tracer-flare/src/main/java/datadog/smoketest/flare/SimpleApp.java new file mode 100644 index 00000000000..d96431ed51a --- /dev/null +++ b/dd-smoke-tests/tracer-flare/src/main/java/datadog/smoketest/flare/SimpleApp.java @@ -0,0 +1,15 @@ +package datadog.smoketest.flare; + +public class SimpleApp { + public static void main(String[] args) throws InterruptedException { + System.out.println("SimpleApp starting - waiting for flare generation"); + + // Keep the app running indefinitely + // The flare will be triggered after 10 seconds (configured in test) + // The test will wait for the flare and then terminate the process + while (true) { + System.out.println("SimpleApp running..."); + Thread.sleep(5000); + } + } +} diff --git a/dd-smoke-tests/tracer-flare/src/test/groovy/datadog/smoketest/TracerFlareSmokeTest.groovy b/dd-smoke-tests/tracer-flare/src/test/groovy/datadog/smoketest/TracerFlareSmokeTest.groovy new file mode 100644 index 00000000000..2f269fb768a --- /dev/null +++ b/dd-smoke-tests/tracer-flare/src/test/groovy/datadog/smoketest/TracerFlareSmokeTest.groovy @@ -0,0 +1,196 @@ +package datadog.smoketest + +import spock.lang.Shared + +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +/** + * This smoke test can be extended with additional configurations of the agent and assertions on the files + * expected to be present in the resulting flare(s). Checking individual file contents should be done a per-reporter + * basis. + * + * For DD Employees - if you update this file alongside changes to an existing reporter or the creation of a new one, + * please also document it in the + * Tracer Flare Wiki + */ +class TracerFlareSmokeTest extends AbstractSmokeTest { + + // Time in seconds after which flare is triggered + private static final int FLARE_TRIGGER_SECONDS = 10 + // Additional buffer time to ensure flare is written to disk + private static final int FLARE_WRITE_BUFFER_SECONDS = 5 + // Number of processes to run in parallel for testing + private static final int NUMBER_OF_PROCESSES = 2 + + protected int numberOfProcesses() { + NUMBER_OF_PROCESSES + } + + @Shared + final flareDirs = [ + File.createTempDir("flare-test-profiling-enabled-", ""), + File.createTempDir("flare-test-profiling-disabled-", "") + ] + + def cleanupSpec() { + flareDirs.each { dir -> + if (dir.exists()) { + dir.deleteDir() + } + } + } + + @Override + ProcessBuilder createProcessBuilder(int processIndex) { + String jarPath = System.getProperty("datadog.smoketest.tracer-flare.jar.path") + File flareDir = flareDirs[processIndex] + + def command = [javaPath()] + + if (processIndex == 0) { + // Process 0: Profiling enabled (default) + command.addAll(defaultJavaProperties) + } else { + // Process 1: Profiling disabled + def filteredProperties = defaultJavaProperties.findAll { prop -> + !prop.startsWith("-Ddd.profiling.") + } + command.addAll(filteredProperties) + command.add("-Ddd.profiling.enabled=false") + } + + // Configure flare generation + command.addAll([ + "-Ddd.triage.report.trigger=${FLARE_TRIGGER_SECONDS}s", + "-Ddd.triage.report.dir=${flareDir.absolutePath}", + "-Ddd.trace.debug=true", + // Enable debug to get more files + '-jar', + jarPath + ] as String[]) + + new ProcessBuilder(command).tap { + it.directory(new File(buildDirectory)) + } + } + + // Core files that should always be present + private static final CORE_FILES = [ + "flare_info.txt", + "tracer_version.txt", + "initial_config.txt", + "dynamic_config.txt", + "jvm_args.txt", + "classpath.txt", + "library_path.txt", + "threads.txt", + // Should be present with triage=true + // Files from CoreTracer + "tracer_health.txt", + "span_metrics.txt", + // Files from InstrumenterFlare (always registered) + "instrumenter_state.txt", + "instrumenter_metrics.txt", + // Files from DI + "dynamic_instrumentation.txt" + ] as Set + + // Optional files that may or may not be present depending on conditions + private static final OPTIONAL_FILES = [ + "boot_classpath.txt", + // Only if JVM supports it (Java 8) + "tracer.log", + // Only if logging is configured + "tracer_begin.log", + // Alternative log format + "tracer_end.log", + // Alternative log format + "flare_errors.txt", + // Only if there were errors + "pending_traces.txt" // Only if there were traces pending transmission + ] as Set + + // Profiling-related files + private static final PROFILING_FILES = [ + "profiler_config.txt", + // Only if profiling is enabled + "profiling_template_override.jfp" // Only if template override is configured + ] as Set + + def "tracer generates flare with profiling enabled (default)"() { + given: + // Wait for flare to be generated (triggered after FLARE_TRIGGER_SECONDS + buffer time) + Thread.sleep((FLARE_TRIGGER_SECONDS + FLARE_WRITE_BUFFER_SECONDS) * 1000) + + when: + // Find the generated flare file from process 0 + def flareFile = findFlareFile(flareDirs[0]) + def zipContents = extractZipContents(flareFile) + + then: + // Verify core files are present + CORE_FILES.each { file -> + assert file in zipContents : "Missing required core file: ${file}" + } + + // Verify profiling files are present (profiling is enabled in defaultJavaProperties) + assert "profiler_config.txt" in zipContents : "Missing profiler_config.txt when profiling is enabled" + + // Check for unexpected files and fail if found + validateNoUnexpectedFiles(zipContents, CORE_FILES + OPTIONAL_FILES + PROFILING_FILES) + } + + def "tracer generates flare with profiling disabled"() { + when: + // Find the generated flare file from process 1 + // The flare should already be generated from the wait in the first test + def flareFile = findFlareFile(flareDirs[1]) + def zipContents = extractZipContents(flareFile) + + then: + // Verify core files are present + CORE_FILES.each { file -> + assert file in zipContents : "Missing required core file: ${file}" + } + + // Verify NO profiling files are present when profiling is disabled + PROFILING_FILES.each { file -> + assert !(file in zipContents) : "Found profiling file '${file}' when profiling is disabled" + } + + // Check for unexpected files and fail if found (profiling files excluded from expected) + validateNoUnexpectedFiles(zipContents, CORE_FILES + OPTIONAL_FILES) + } + + private static File findFlareFile(File flareDir) { + def flareFiles = flareDir.listFiles({ File dir, String name -> + name.startsWith("dd-java-flare-") && name.endsWith(".zip") + } as FilenameFilter) + assert flareFiles.size() == 1 : "Expected exactly one flare file, found: ${flareFiles.size()}" + flareFiles.first() + } + + private static void validateNoUnexpectedFiles(Set zipContents, Set expectedFiles) { + def unexpectedFiles = zipContents - expectedFiles + assert !unexpectedFiles : "Found unexpected files in flare: ${unexpectedFiles}" + } + + private static Set extractZipContents(File zipFile) { + def fileNames = [] + + zipFile.withInputStream { stream -> + new ZipInputStream(stream).withCloseable { zis -> + ZipEntry entry + while ((entry = zis.nextEntry) != null) { + if (!entry.directory) { + fileNames << entry.name + } + zis.closeEntry() + } + } + } + + fileNames + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2de3e282faa..cfd3bc5f748 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -215,6 +215,7 @@ include( ":dd-smoke-tests:springboot-tomcat", ":dd-smoke-tests:springboot-tomcat-jsp", ":dd-smoke-tests:springboot-velocity", + ":dd-smoke-tests:tracer-flare", ":dd-smoke-tests:vertx-3.4", ":dd-smoke-tests:vertx-3.9", ":dd-smoke-tests:vertx-3.9-resteasy", From 2f8c79eb55c299fe990c8529af9d84363fc36324 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 2 Sep 2025 10:54:48 -0400 Subject: [PATCH 2/2] Replace naive wait with file watching --- .../smoketest/TracerFlareSmokeTest.groovy | 120 +++++++++++++++--- 1 file changed, 100 insertions(+), 20 deletions(-) diff --git a/dd-smoke-tests/tracer-flare/src/test/groovy/datadog/smoketest/TracerFlareSmokeTest.groovy b/dd-smoke-tests/tracer-flare/src/test/groovy/datadog/smoketest/TracerFlareSmokeTest.groovy index 2f269fb768a..9ecd92795c3 100644 --- a/dd-smoke-tests/tracer-flare/src/test/groovy/datadog/smoketest/TracerFlareSmokeTest.groovy +++ b/dd-smoke-tests/tracer-flare/src/test/groovy/datadog/smoketest/TracerFlareSmokeTest.groovy @@ -2,6 +2,13 @@ package datadog.smoketest import spock.lang.Shared +import java.nio.file.FileSystems +import java.nio.file.Path +import java.nio.file.StandardWatchEventKinds +import java.nio.file.WatchEvent +import java.nio.file.WatchKey +import java.nio.file.WatchService +import java.util.concurrent.TimeUnit import java.util.zip.ZipEntry import java.util.zip.ZipInputStream @@ -17,9 +24,7 @@ import java.util.zip.ZipInputStream class TracerFlareSmokeTest extends AbstractSmokeTest { // Time in seconds after which flare is triggered - private static final int FLARE_TRIGGER_SECONDS = 10 - // Additional buffer time to ensure flare is written to disk - private static final int FLARE_WRITE_BUFFER_SECONDS = 5 + private static final int FLARE_TRIGGER_SECONDS = 15 // Number of processes to run in parallel for testing private static final int NUMBER_OF_PROCESSES = 2 @@ -118,14 +123,22 @@ class TracerFlareSmokeTest extends AbstractSmokeTest { "profiling_template_override.jfp" // Only if template override is configured ] as Set - def "tracer generates flare with profiling enabled (default)"() { - given: - // Wait for flare to be generated (triggered after FLARE_TRIGGER_SECONDS + buffer time) - Thread.sleep((FLARE_TRIGGER_SECONDS + FLARE_WRITE_BUFFER_SECONDS) * 1000) + // Flare file naming pattern constants + private static final String FLARE_FILE_PREFIX = "dd-java-flare-" + private static final String FLARE_FILE_EXTENSION = ".zip" + + /** + * Checks if a filename matches the expected flare file pattern + */ + private static boolean isFlareFile(String fileName) { + fileName.startsWith(FLARE_FILE_PREFIX) && fileName.endsWith(FLARE_FILE_EXTENSION) + } + def "tracer generates flare with profiling enabled (default)"() { when: - // Find the generated flare file from process 0 - def flareFile = findFlareFile(flareDirs[0]) + // Wait for flare file to be created using filesystem watcher + // The flare is triggered after FLARE_TRIGGER_SECONDS, plus some write time + def flareFile = waitForFlareFile(flareDirs[0]) def zipContents = extractZipContents(flareFile) then: @@ -143,9 +156,9 @@ class TracerFlareSmokeTest extends AbstractSmokeTest { def "tracer generates flare with profiling disabled"() { when: - // Find the generated flare file from process 1 - // The flare should already be generated from the wait in the first test - def flareFile = findFlareFile(flareDirs[1]) + // Wait for flare file to be created independently for process 1 + // Each test should be independent and not rely on timing from other tests + def flareFile = waitForFlareFile(flareDirs[1]) def zipContents = extractZipContents(flareFile) then: @@ -163,14 +176,6 @@ class TracerFlareSmokeTest extends AbstractSmokeTest { validateNoUnexpectedFiles(zipContents, CORE_FILES + OPTIONAL_FILES) } - private static File findFlareFile(File flareDir) { - def flareFiles = flareDir.listFiles({ File dir, String name -> - name.startsWith("dd-java-flare-") && name.endsWith(".zip") - } as FilenameFilter) - assert flareFiles.size() == 1 : "Expected exactly one flare file, found: ${flareFiles.size()}" - flareFiles.first() - } - private static void validateNoUnexpectedFiles(Set zipContents, Set expectedFiles) { def unexpectedFiles = zipContents - expectedFiles assert !unexpectedFiles : "Found unexpected files in flare: ${unexpectedFiles}" @@ -193,4 +198,79 @@ class TracerFlareSmokeTest extends AbstractSmokeTest { fileNames } + + /** + * Waits for a flare file to be created in the specified directory using filesystem watching. + * + * @param flareDir The directory to watch for flare files + * @param timeoutSeconds Maximum time to wait for the file + * @return The created flare file + * @throws AssertionError if no flare file is created within the timeout + */ + private static File waitForFlareFile(File flareDir, int timeoutSeconds = FLARE_TRIGGER_SECONDS + 5) { + Path dirPath = flareDir.toPath() + WatchService watchService = FileSystems.getDefault().newWatchService() + + try { + def existingFile = findFlareFileIfExists(flareDir) + if (existingFile) { + return existingFile + } + + dirPath.register(watchService, StandardWatchEventKinds.ENTRY_CREATE) + long deadlineMillis = System.currentTimeMillis() + (timeoutSeconds * 1000) + + while (System.currentTimeMillis() < deadlineMillis) { + long remainingMillis = deadlineMillis - System.currentTimeMillis() + if (remainingMillis <= 0) { + break + } + + WatchKey key = watchService.poll(remainingMillis, TimeUnit.MILLISECONDS) + if (key == null) { + existingFile = findFlareFileIfExists(flareDir) + if (existingFile) { + return existingFile + } + break + } + + for (WatchEvent event : key.pollEvents()) { + WatchEvent pathEvent = (WatchEvent) event + Path fileName = pathEvent.context() + + if (isFlareFile(fileName.toString())) { + return new File(flareDir, fileName.toString()) + } + } + + boolean valid = key.reset() + if (!valid) { + throw new AssertionError("Watch directory ${flareDir} is no longer accessible") + } + } + + existingFile = findFlareFileIfExists(flareDir) + if (existingFile) { + return existingFile + } + + throw new AssertionError("No flare file created in ${flareDir} within ${timeoutSeconds} seconds") + + } finally { + watchService.close() + } + } + + /** + * Attempts to find an existing flare file in the directory. + * Returns null if no flare file exists. + */ + private static File findFlareFileIfExists(File flareDir) { + def flareFiles = flareDir.listFiles({ File dir, String name -> + isFlareFile(name) + } as FilenameFilter) + + return flareFiles?.size() > 0 ? flareFiles.first() : null + } }