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);