From 5c0e5882f5d103802ec5b33503f5d3e190fd034c Mon Sep 17 00:00:00 2001 From: marr97 Date: Wed, 6 May 2026 02:31:19 +0300 Subject: [PATCH] feat: add file output(json/csv) --- README.md | 5 + volta-master/pom.xml | 4 + .../java/com/volta/master/StartupRunner.java | 3 +- .../java/com/volta/master/cli/ArgsParser.java | 73 +++++- .../java/com/volta/master/cli/MasterArgs.java | 3 +- .../volta/master/reporter/StatsReporter.java | 217 ++++++++++++++++-- .../master/reporter/StatsReporterTest.java | 36 ++- 7 files changed, 301 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 0060f5b..9e186ae 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,11 @@ java -jar volta-master/target/volta-master-1.0-SNAPSHOT.jar \ --agent=localhost:7070 ``` +Stats file (optional): `--stats-out=path` + +- Ends with `.csv` → header row + CSV lines (`sample` each second + one `final` row). +- Any other suffix (for example `.jsonl`) → one JSON object per line (serialized `StatsSnapshot`). + Expected output: ``` [RPS: 0 | Success: 0.0% | Avg: 0ms | Errors: 0] diff --git a/volta-master/pom.xml b/volta-master/pom.xml index 96e8b82..8ac96ec 100644 --- a/volta-master/pom.xml +++ b/volta-master/pom.xml @@ -68,6 +68,10 @@ mockito-junit-jupiter test + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + diff --git a/volta-master/src/main/java/com/volta/master/StartupRunner.java b/volta-master/src/main/java/com/volta/master/StartupRunner.java index 1ce0cfe..1e2b6cc 100644 --- a/volta-master/src/main/java/com/volta/master/StartupRunner.java +++ b/volta-master/src/main/java/com/volta/master/StartupRunner.java @@ -44,6 +44,7 @@ public void run(ApplicationArguments args) { agentClient.startTest(masterArgs.agentUrl(), masterArgs.testConfig()); - statsReporter.startReporting(masterArgs.agentUrl(), masterArgs.testConfig().duration()); + statsReporter.startReporting( + masterArgs.agentUrl(), masterArgs.testConfig().duration(), masterArgs.outputFile()); } } diff --git a/volta-master/src/main/java/com/volta/master/cli/ArgsParser.java b/volta-master/src/main/java/com/volta/master/cli/ArgsParser.java index dffb53d..480c30a 100644 --- a/volta-master/src/main/java/com/volta/master/cli/ArgsParser.java +++ b/volta-master/src/main/java/com/volta/master/cli/ArgsParser.java @@ -1,28 +1,83 @@ package com.volta.master.cli; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.volta.model.TestConfig; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; import org.springframework.boot.ApplicationArguments; public class ArgsParser { private static final String USAGE = """ - Usage: java -jar volta-master.jar --url= --rps= --duration= --agent= - Example: java -jar volta-master.jar --url=https://httpbin.org/get --rps=10 --duration=30 --agent=localhost:7070 - """; + Usage: java -jar volta-master.jar --url= --rps= --duration= --agent= + java -jar volta-master.jar --config=./ --agent= + + Example: java -jar volta-master.jar --url=https://httpbin.org/get --rps=10 --duration=30 --agent=localhost:7070 + java -jar volta-master.jar --config=./config.json --agent=localhost:7070 [--stats-out=report.jsonl|report.csv] + """; public static MasterArgs parse(ApplicationArguments args) { - String url = requireString(args, "url"); - int rps = requirePositiveInt(args, "rps"); - int duration = requirePositiveInt(args, "duration"); - String agent = requireString(args, "agent"); - validateUrl(url); + TestConfig testConfig; + if (args.containsOption("config")) { + String configPath = requireString(args, "config"); + + Path path = Path.of(configPath); + if (!Files.exists(path)) { + throw new ArgsException("Config file not found: " + configPath + "\n" + USAGE); + } + if (!Files.isRegularFile(path)) { + throw new ArgsException("Config path is not a regular file: " + configPath + "\n" + USAGE); + } + + ObjectMapper mapper; + String name = path.getFileName().toString().toLowerCase(); + if (name.endsWith(".json")) { + mapper = new ObjectMapper(); + } else if (name.endsWith(".yaml") || name.endsWith(".yml")) { + mapper = new ObjectMapper(new YAMLFactory()); + } else { + throw new ArgsException("Unsupported file format: " + configPath + "\n" + USAGE); + } + + try { + testConfig = mapper.readValue(path.toFile(), TestConfig.class); + } catch (IOException e) { + throw new ArgsException( + "Failed to read/parse config: " + configPath + "\n" + e.getMessage() + "\n" + USAGE); + } + + if (testConfig.rps() <= 0) { + throw new ArgsException("Argument rps in config must be positive\n" + USAGE); + } + if (testConfig.duration() <= 0) { + throw new ArgsException("Argument duration in config must be positive\n" + USAGE); + } + validateUrl(testConfig.url()); + + } else { + String url = requireString(args, "url"); + int rps = requirePositiveInt(args, "rps"); + int duration = requirePositiveInt(args, "duration"); + + validateUrl(url); + testConfig = new TestConfig(url, rps, duration); + } + String agent = requireString(args, "agent"); validateAgent(agent); agent = "http://" + agent; - return new MasterArgs(new TestConfig(url, rps, duration), agent); + if (args.containsOption("stats-out")) { + String outputFile = requireString(args, "stats-out"); + return new MasterArgs(testConfig, Optional.of(outputFile), agent); + } + + return new MasterArgs(testConfig, Optional.empty(), agent); } private static String requireString(ApplicationArguments args, String name) { diff --git a/volta-master/src/main/java/com/volta/master/cli/MasterArgs.java b/volta-master/src/main/java/com/volta/master/cli/MasterArgs.java index 02798c7..107d47b 100644 --- a/volta-master/src/main/java/com/volta/master/cli/MasterArgs.java +++ b/volta-master/src/main/java/com/volta/master/cli/MasterArgs.java @@ -1,5 +1,6 @@ package com.volta.master.cli; import com.volta.model.TestConfig; +import java.util.Optional; -public record MasterArgs(TestConfig testConfig, String agentUrl) {} +public record MasterArgs(TestConfig testConfig, Optional outputFile, String agentUrl) {} diff --git a/volta-master/src/main/java/com/volta/master/reporter/StatsReporter.java b/volta-master/src/main/java/com/volta/master/reporter/StatsReporter.java index bff0794..f5fe495 100644 --- a/volta-master/src/main/java/com/volta/master/reporter/StatsReporter.java +++ b/volta-master/src/main/java/com/volta/master/reporter/StatsReporter.java @@ -1,8 +1,17 @@ package com.volta.master.reporter; +import com.fasterxml.jackson.databind.ObjectMapper; import com.volta.master.StartupRunner; import com.volta.master.client.AgentClient; import com.volta.stats.StatsSnapshot; +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Locale; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; @@ -11,6 +20,11 @@ public class StatsReporter { private static final Logger log = LoggerFactory.getLogger(StartupRunner.class); + private static final Locale CSV_LOCALE = Locale.ROOT; + + private static final String CSV_HEADER = + "kind,elapsedSeconds,totalRequests,successCount,errorCount,avgLatencyMs,minLatencyMs,maxLatencyMs,successRatePercent,cumulativeAvgRps"; + // ANSI-codes for colors public static final String RESET = "\u001B[0m"; public static final String GREEN = "\u001B[32m"; @@ -20,15 +34,86 @@ public class StatsReporter { public static final String CYAN = "\u001B[36m"; public static final String BOLD = "\u001B[1m"; + private enum StatsFileFormat { + JSONL, + CSV + } + + private record StatsFileSink(BufferedWriter writer, StatsFileFormat format) {} + private final AgentClient agentClient; + private final ObjectMapper jsonMapper = new ObjectMapper(); public StatsReporter(AgentClient agentClient) { this.agentClient = agentClient; } - public void startReporting(String agentUrl, int durationSeconds) { + public void startReporting( + String agentUrl, int durationSeconds, Optional outputFilePath) { + log.info("Starting live stats reporting for {}s", durationSeconds); + Optional sink = openOptionalSink(outputFilePath); + try { + runLoop(agentUrl, durationSeconds, sink); + try { + StatsSnapshot finalStats = agentClient.getStats(agentUrl); + writeFinalToFile(sink, finalStats); + printFinalStats(finalStats); + } catch (Exception e) { + log.error("Failed to fetch final stats: {}", e.getMessage()); + } + } finally { + if (sink.isPresent()) { + try { + sink.get().writer().close(); + } catch (IOException e) { + log.warn("Failed to close stats file: {}", e.getMessage()); + } + } + } + } + + private static StatsFileFormat detectFormat(Path path) { + String name = path.getFileName().toString().toLowerCase(Locale.ROOT); + if (name.endsWith(".csv")) { + return StatsFileFormat.CSV; + } + return StatsFileFormat.JSONL; + } + + private Optional openOptionalSink(Optional outputFilePath) { + if (outputFilePath.isEmpty()) { + return Optional.empty(); + } + Path path = Path.of(outputFilePath.get()); + try { + Path parent = path.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + BufferedWriter writer = + Files.newBufferedWriter( + path, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE); + StatsFileFormat format = detectFormat(path); + if (format == StatsFileFormat.CSV) { + writer.write(CSV_HEADER); + writer.newLine(); + writer.flush(); + } + return Optional.of(new StatsFileSink(writer, format)); + } catch (IOException e) { + log.error("Failed to open stats output '{}', continuing without file", path, e); + return Optional.empty(); + } + } + + private void runLoop(String agentUrl, int durationSeconds, Optional sink) { + for (int i = 0; i < durationSeconds; i++) { try { Thread.sleep(1000); @@ -40,16 +125,109 @@ public void startReporting(String agentUrl, int durationSeconds) { try { StatsSnapshot stats = agentClient.getStats(agentUrl); - printLiveStats(stats, i + 1); + int elapsedSeconds = i + 1; + printLiveStats(stats, elapsedSeconds); + writeSampleToFile(sink, stats, elapsedSeconds); } catch (Exception e) { log.error("Failed to fetch stats: {}", e.getMessage()); } } + } + + private void writeSampleToFile( + Optional sink, StatsSnapshot stats, int elapsedSeconds) { + + if (sink.isEmpty()) { + return; + } + StatsFileSink s = sink.get(); + try { + if (s.format() == StatsFileFormat.JSONL) { + jsonLineOut(s.writer(), stats); + return; + } + csvRowSample(s.writer(), stats, elapsedSeconds); + } catch (IOException e) { + log.warn("Failed to write stats line: {}", e.getMessage()); + } + } + + private void writeFinalToFile(Optional sink, StatsSnapshot stats) { + + if (sink.isEmpty()) { + return; + } + StatsFileSink s = sink.get(); + try { + if (s.format() == StatsFileFormat.JSONL) { + jsonLineOut(s.writer(), stats); + return; + } + csvRowFinal(s.writer(), stats); + } catch (IOException e) { + log.warn("Failed to write stats line: {}", e.getMessage()); + } + } + + private void jsonLineOut(BufferedWriter writer, StatsSnapshot stats) throws IOException { + writer.write(jsonMapper.writeValueAsString(stats)); + writer.newLine(); + writer.flush(); + } + + private void csvRowSample(BufferedWriter writer, StatsSnapshot stats, int elapsedSeconds) + throws IOException { + writer.write(csvLineSample(stats, elapsedSeconds)); + writer.newLine(); + writer.flush(); + } + + private void csvRowFinal(BufferedWriter writer, StatsSnapshot stats) throws IOException { + writer.write(csvLineFinal(stats)); + writer.newLine(); + writer.flush(); + } + + private String csvLineSample(StatsSnapshot stats, int elapsedSeconds) { + + double successRate = calculateSuccessRate(stats); + long cumulativeAvgRps = calculateCurrentRps(stats, elapsedSeconds); + return String.format( + CSV_LOCALE, + "sample,%d,%d,%d,%d,%s,%d,%d,%s,%d", + elapsedSeconds, + stats.totalRequests(), + stats.successCount(), + stats.errorCount(), + formatDoubleCsv(stats.avgLatencyMs()), + stats.minLatencyMs(), + stats.maxLatencyMs(), + formatDoubleCsv(successRate), + cumulativeAvgRps); + } + + private String csvLineFinal(StatsSnapshot stats) { - printFinalStats(agentUrl); + double successRate = calculateSuccessRate(stats); + return String.format( + CSV_LOCALE, + "final,,%d,%d,%d,%s,%d,%d,%s,", + stats.totalRequests(), + stats.successCount(), + stats.errorCount(), + formatDoubleCsv(stats.avgLatencyMs()), + stats.minLatencyMs(), + stats.maxLatencyMs(), + formatDoubleCsv(successRate)); + } + + private static String formatDoubleCsv(double value) { + + return String.format(CSV_LOCALE, "%.4f", value); } private void printLiveStats(StatsSnapshot stats, int elapsedSeconds) { + double successRate = calculateSuccessRate(stats); long currentRps = calculateCurrentRps(stats, elapsedSeconds); @@ -75,33 +253,27 @@ private void printLiveStats(StatsSnapshot stats, int elapsedSeconds) { System.out.println(line); } - private void printFinalStats(String agentUrl) { - try { - StatsSnapshot finalStats = agentClient.getStats(agentUrl); + private void printFinalStats(StatsSnapshot finalStats) { - System.out.println("\n" + BOLD + CYAN + "========= FINAL STATS =========" + RESET); + System.out.println("\n" + BOLD + CYAN + "========= FINAL STATS =========" + RESET); - System.out.printf( - "Total Requests: %s%s%d%s\n", BOLD, BLUE, finalStats.totalRequests(), RESET); - System.out.printf( - "Success: %s%s%d%s\n", BOLD, GREEN, finalStats.successCount(), RESET); - System.out.printf("Errors: %s%s%d%s\n", BOLD, RED, finalStats.errorCount(), RESET); + System.out.printf("Total Requests: %s%s%d%s\n", BOLD, BLUE, finalStats.totalRequests(), RESET); + System.out.printf("Success: %s%s%d%s\n", BOLD, GREEN, finalStats.successCount(), RESET); + System.out.printf("Errors: %s%s%d%s\n", BOLD, RED, finalStats.errorCount(), RESET); - System.out.printf( - "Success Rate: %s%.2f%%%s\n", BOLD, calculateSuccessRate(finalStats), RESET); - System.out.printf( - "Avg Latency: %s%s%.2fms%s\n", BOLD, YELLOW, finalStats.avgLatencyMs(), RESET); + System.out.printf( + "Success Rate: %s%.2f%%%s\n", BOLD, calculateSuccessRate(finalStats), RESET); + System.out.printf( + "Avg Latency: %s%s%.2fms%s\n", BOLD, YELLOW, finalStats.avgLatencyMs(), RESET); - System.out.printf("Min Latency: %dms\n", finalStats.minLatencyMs()); - System.out.printf("Max Latency: %dms\n", finalStats.maxLatencyMs()); + System.out.printf("Min Latency: %dms\n", finalStats.minLatencyMs()); + System.out.printf("Max Latency: %dms\n", finalStats.maxLatencyMs()); - System.out.println(BOLD + CYAN + "===============================" + RESET); - } catch (Exception e) { - log.error("Failed to fetch final stats: {}", e.getMessage()); - } + System.out.println(BOLD + CYAN + "===============================" + RESET); } private double calculateSuccessRate(StatsSnapshot stats) { + if (stats.totalRequests() == 0) { return 0.0; } @@ -109,6 +281,7 @@ private double calculateSuccessRate(StatsSnapshot stats) { } private long calculateCurrentRps(StatsSnapshot stats, int elapsedSeconds) { + if (elapsedSeconds == 0) { return 0; } diff --git a/volta-master/src/test/java/com/volta/master/reporter/StatsReporterTest.java b/volta-master/src/test/java/com/volta/master/reporter/StatsReporterTest.java index 5afc092..815cb26 100644 --- a/volta-master/src/test/java/com/volta/master/reporter/StatsReporterTest.java +++ b/volta-master/src/test/java/com/volta/master/reporter/StatsReporterTest.java @@ -7,8 +7,13 @@ import com.volta.stats.StatsSnapshot; import java.io.ByteArrayOutputStream; import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.mockito.Mock; import org.mockito.MockitoAnnotations; @@ -33,7 +38,7 @@ void shouldPollStatsEverySecond() { StatsSnapshot mockStats = new StatsSnapshot(100, 95, 5, 50.0, 10, 200); when(agentClient.getStats(anyString())).thenReturn(mockStats); - statsReporter.startReporting("http://localhost:7070", 3); + statsReporter.startReporting("http://localhost:7070", 3, Optional.empty()); verify(agentClient, times(4)).getStats("http://localhost:7070"); } @@ -44,7 +49,7 @@ void shouldPrintLiveStatsInCorrectFormat() { StatsSnapshot mockStats = new StatsSnapshot(100, 98, 2, 45.5, 10, 150); when(agentClient.getStats(anyString())).thenReturn(mockStats); - statsReporter.startReporting("http://localhost:7070", 1); + statsReporter.startReporting("http://localhost:7070", 1, Optional.empty()); String output = outputCapture.toString(); assertTrue(output.contains("RPS:")); @@ -59,7 +64,7 @@ void shouldPrintFinalStatsAfterLoop() { StatsSnapshot mockStats = new StatsSnapshot(100, 95, 5, 50.0, 10, 200); when(agentClient.getStats(anyString())).thenReturn(mockStats); - statsReporter.startReporting("http://localhost:7070", 1); + statsReporter.startReporting("http://localhost:7070", 1, Optional.empty()); String output = outputCapture.toString(); assertTrue(output.contains("FINAL STATS")); @@ -73,7 +78,7 @@ void shouldCalculateSuccessRateCorrectly() { StatsSnapshot stats = new StatsSnapshot(100, 95, 5, 50.0, 10, 200); when(agentClient.getStats(anyString())).thenReturn(stats); - statsReporter.startReporting("http://localhost:7070", 1); + statsReporter.startReporting("http://localhost:7070", 1, Optional.empty()); String output = outputCapture.toString(); assertTrue(output.contains("95") || output.contains("95.0")); @@ -85,7 +90,7 @@ void shouldHandleZeroRequests() { StatsSnapshot emptyStats = new StatsSnapshot(0, 0, 0, 0.0, 0, 0); when(agentClient.getStats(anyString())).thenReturn(emptyStats); - statsReporter.startReporting("http://localhost:7070", 1); + statsReporter.startReporting("http://localhost:7070", 1, Optional.empty()); String output = outputCapture.toString(); assertTrue(output.contains("RPS: 0")); @@ -98,10 +103,27 @@ void shouldContinueOnStatsError() { .thenThrow(new RuntimeException("Network error")) .thenReturn(new StatsSnapshot(10, 10, 0, 50.0, 10, 100)); - assertDoesNotThrow(() -> statsReporter.startReporting("http://localhost:7070", 2)); + assertDoesNotThrow( + () -> statsReporter.startReporting("http://localhost:7070", 2, Optional.empty())); verify(agentClient, atLeast(2)).getStats(anyString()); } + @Test + void shouldWriteCsvWithHeaderSampleAndFinalRows(@TempDir Path tempDir) throws Exception { + Path out = tempDir.resolve("report.csv"); + StatsSnapshot mockStats = new StatsSnapshot(100, 95, 5, 50.0, 10, 200); + when(agentClient.getStats(anyString())).thenReturn(mockStats); + + statsReporter.startReporting("http://localhost:7070", 2, Optional.of(out.toString())); + + List lines = Files.readAllLines(out); + assertEquals(4, lines.size(), "header + 2 sample rows + final"); + assertTrue(lines.get(0).startsWith("kind,elapsedSeconds,")); + assertTrue(lines.get(1).startsWith("sample,")); + assertTrue(lines.get(2).startsWith("sample,")); + assertTrue(lines.get(3).startsWith("final,")); + } + @Test void shouldStopAfterSpecifiedDuration() { // Verify that reporting loop stops after the specified duration (±500ms tolerance) @@ -109,7 +131,7 @@ void shouldStopAfterSpecifiedDuration() { when(agentClient.getStats(anyString())).thenReturn(mockStats); long startTime = System.currentTimeMillis(); - statsReporter.startReporting("http://localhost:7070", 2); + statsReporter.startReporting("http://localhost:7070", 2, Optional.empty()); long elapsed = System.currentTimeMillis() - startTime; assertTrue(elapsed >= 2000 && elapsed < 3000);