001/*
002 * Copyright © 2025 CUI-OpenSource-Software (info@cuioss.de)
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package de.cuioss.benchmarking.common.converter;
017
018import de.cuioss.benchmarking.common.config.BenchmarkType;
019import de.cuioss.benchmarking.common.model.BenchmarkData;
020import de.cuioss.tools.logging.CuiLogger;
021
022import java.io.IOException;
023import java.nio.file.Files;
024import java.nio.file.Path;
025import java.time.Instant;
026import java.time.ZoneOffset;
027import java.time.format.DateTimeFormatter;
028import java.util.*;
029import java.util.regex.Matcher;
030import java.util.regex.Pattern;
031
032import static de.cuioss.benchmarking.common.util.BenchmarkingLogMessages.ERROR.FAILED_PARSE_WRK_FILE;
033
034/**
035 * Converts WRK benchmark output to the central BenchmarkData model
036 */
037@SuppressWarnings("java:S5852") // ok for test-data
038public class WrkBenchmarkConverter implements BenchmarkConverter {
039
040    private static final CuiLogger LOGGER = new CuiLogger(WrkBenchmarkConverter.class);
041
042    // Regex patterns for parsing WRK output
043    private static final Pattern REQUESTS_PER_SEC = Pattern.compile("Requests/sec:\\s+([\\d.]+)");
044    private static final Pattern LATENCY_STATS = Pattern.compile(
045            "Latency\\s+([\\d.]+)(\\w+)\\s+([\\d.]+)(\\w+)\\s+([\\d.]+)(\\w+)\\s+([\\d.]+)%");
046    private static final Pattern LATENCY_PERCENTILE = Pattern.compile(
047            "\\s+(\\d+(?:\\.\\d+)?)%\\s+([\\d.]+)(\\w+)");
048
049    @Override
050    public BenchmarkData convert(Path sourcePath) throws IOException {
051        if (sourcePath.toFile().isDirectory()) {
052            // Convert all WRK output files in directory
053            return convertDirectory(sourcePath);
054        } else {
055            // Convert single WRK output file
056            return convertFile(sourcePath);
057        }
058    }
059
060    @Override
061    public boolean canConvert(Path sourcePath) {
062        if (sourcePath.toFile().isDirectory()) {
063            return sourcePath.getFileName().toString().contains("wrk");
064        }
065        String fileName = sourcePath.getFileName().toString();
066        return fileName.contains("wrk") && fileName.endsWith(".txt");
067    }
068
069    private BenchmarkData convertDirectory(Path dir) throws IOException {
070        List<BenchmarkData.Benchmark> benchmarks = new ArrayList<>();
071
072        // Process all .txt files in the directory (should only contain WRK results)
073        try (var stream = Files.list(dir)) {
074            stream.filter(p -> p.getFileName().toString().endsWith(".txt"))
075                    .forEach(file -> {
076                        try {
077                            LOGGER.debug("Processing WRK result file: " + file.getFileName());
078                            BenchmarkData.Benchmark benchmark = parseWrkFile(file);
079                            if (benchmark != null) {
080                                benchmarks.add(benchmark);
081                            }
082                        } catch (IOException e) {
083                            LOGGER.error(e, FAILED_PARSE_WRK_FILE, file);
084                        }
085                    });
086        }
087
088        return BenchmarkData.builder()
089                .metadata(createMetadata())
090                .overview(createOverview(benchmarks))
091                .benchmarks(benchmarks)
092                .build();
093    }
094
095    private BenchmarkData convertFile(Path file) throws IOException {
096        BenchmarkData.Benchmark benchmark = parseWrkFile(file);
097        List<BenchmarkData.Benchmark> benchmarks = benchmark != null ?
098                List.of(benchmark) : Collections.emptyList();
099
100        return BenchmarkData.builder()
101                .metadata(createMetadata())
102                .overview(createOverview(benchmarks))
103                .benchmarks(benchmarks)
104                .build();
105    }
106
107    private BenchmarkData.Benchmark parseWrkFile(Path file) throws IOException {
108        String content = Files.readString(file);
109        String name = extractBenchmarkName(file);
110
111        // Parse requests per second (throughput)
112        double requestsPerSec = 0;
113        Matcher m = REQUESTS_PER_SEC.matcher(content);
114        if (m.find()) {
115            requestsPerSec = Double.parseDouble(m.group(1));
116        }
117
118        // Parse latency statistics
119        double latencyAvg = 0;
120        double latencyStdev = 0;
121        m = LATENCY_STATS.matcher(content);
122        if (m.find()) {
123            latencyAvg = convertToMs(Double.parseDouble(m.group(1)), m.group(2));
124            latencyStdev = convertToMs(Double.parseDouble(m.group(3)), m.group(4));
125        }
126
127        // Parse percentiles
128        Map<String, Double> percentiles = new LinkedHashMap<>();
129        m = LATENCY_PERCENTILE.matcher(content);
130        while (m.find()) {
131            double percentile = Double.parseDouble(m.group(1));
132            double value = convertToMs(Double.parseDouble(m.group(2)), m.group(3));
133            percentiles.put(String.valueOf(percentile), value);
134        }
135
136        // Ensure standard percentiles exist
137        ensureStandardPercentiles(percentiles, latencyAvg, latencyStdev);
138
139        return BenchmarkData.Benchmark.builder()
140                .name(name)
141                .fullName("wrk." + name)
142                .mode("thrpt")
143                .rawScore(requestsPerSec)
144                .score(formatThroughput(requestsPerSec))
145                .scoreUnit("ops/s")
146                .throughput(formatThroughput(requestsPerSec))
147                .latency(formatLatency(latencyAvg))
148                .error(latencyStdev)
149                .variabilityCoefficient(latencyAvg > 0 ? (latencyStdev / latencyAvg * 100) : 0)
150                .confidenceLow(Math.max(0, latencyAvg - latencyStdev))
151                .confidenceHigh(latencyAvg + latencyStdev)
152                .percentiles(percentiles)
153                .build();
154    }
155
156    private String extractBenchmarkName(Path file) {
157        String fileName = file.getFileName().toString();
158
159        // First, try to extract from embedded metadata if available
160        try {
161            List<String> lines = Files.readAllLines(file);
162            for (String line : lines) {
163                if (line.startsWith("benchmark_name: ")) {
164                    return line.substring(16).trim();
165                }
166                // Stop looking after WRK output starts
167                if ("=== WRK OUTPUT ===".equals(line)) {
168                    break;
169                }
170            }
171        } catch (IOException e) {
172            LOGGER.debug("Could not read metadata from file: " + file, e);
173        }
174
175        // Fallback to filename-based extraction
176        if (fileName.contains("jwt")) {
177            return "jwtValidation";
178        } else if (fileName.contains("health-live")) {
179            return "healthLiveCheck";
180        } else if (fileName.contains("health")) {
181            return "healthCheck";
182        }
183        return fileName.replace("-results.txt", "").replace("wrk-", "").replace("-output", "");
184    }
185
186    private double convertToMs(double value, String unit) {
187        return switch (unit.toLowerCase()) {
188            case "us" -> value / 1000.0;
189            case "s" -> value * 1000.0;
190            case "m" -> value * 60000.0;
191            default -> value; // Handles "ms" and any other units as-is
192        };
193    }
194
195    private void ensureStandardPercentiles(Map<String, Double> percentiles, double avg, double stdev) {
196        String[] standard = {"0.0", "50.0", "90.0", "95.0", "99.0", "99.9", "99.99", "100.0"};
197
198        for (String p : standard) {
199            if (percentiles.containsKey(p)) {
200                continue;
201            }
202            double percentileValue = Double.parseDouble(p);
203            // Simple estimation based on normal distribution
204            double estimated = estimatePercentile(percentileValue, avg, stdev);
205            percentiles.put(p, estimated);
206        }
207    }
208
209    private double estimatePercentile(double percentile, double avg, double stdev) {
210        if (percentile <= 50) {
211            return Math.max(0, avg - stdev * (50 - percentile) / 50);
212        } else {
213            return avg + stdev * (percentile - 50) / 50 * 2;
214        }
215    }
216
217    private BenchmarkData.Metadata createMetadata() {
218        Instant now = Instant.now();
219        return BenchmarkData.Metadata.builder()
220                .timestamp(now.toString())
221                .displayTimestamp(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss 'UTC'")
222                        .withZone(ZoneOffset.UTC)
223                        .format(now))
224                .benchmarkType(BenchmarkType.INTEGRATION.getDisplayName())
225                .reportVersion("2.0")
226                .build();
227    }
228
229    private BenchmarkData.Overview createOverview(List<BenchmarkData.Benchmark> benchmarks) {
230        // Find JWT benchmark (primary)
231        BenchmarkData.Benchmark primary = benchmarks.stream()
232                .filter(b -> b.getName().contains("jwt") || b.getName().contains("Validation"))
233                .findFirst()
234                .orElse(benchmarks.isEmpty() ? null : benchmarks.getFirst());
235
236        if (primary == null) {
237            return BenchmarkData.Overview.builder()
238                    .throughput("N/A")
239                    .latency("N/A")
240                    .throughputOpsPerSec(0.0)
241                    .latencyMs(0.0)
242                    .performanceScore(0)
243                    .performanceGrade("F")
244                    .performanceGradeClass("grade-f")
245                    .build();
246        }
247
248        double throughput = primary.getRawScore();
249        double latencyMs = primary.getPercentiles().getOrDefault("50.0", 100.0);
250        int score = calculatePerformanceScore(throughput, latencyMs);
251        String grade = calculatePerformanceGrade(score);
252
253        return BenchmarkData.Overview.builder()
254                .throughput(primary.getThroughput())
255                .latency(primary.getLatency())
256                .throughputOpsPerSec(throughput)  // Store numeric value used for score calculation
257                .latencyMs(latencyMs)             // Store numeric value used for score calculation
258                .throughputBenchmarkName(primary.getName())
259                .latencyBenchmarkName(primary.getName())
260                .performanceScore(score)
261                .performanceGrade(grade)
262                .performanceGradeClass("grade-" + grade.toLowerCase())
263                .build();
264    }
265
266    private String formatThroughput(double reqPerSec) {
267        if (reqPerSec >= 1000) {
268            return String.format(Locale.US, "%.1fK ops/s", reqPerSec / 1000);
269        }
270        return String.format(Locale.US, "%.0f ops/s", reqPerSec);
271    }
272
273    private String formatLatency(double ms) {
274        if (ms < 1) {
275            return String.format(Locale.US, "%.0fμs", ms * 1000);
276        }
277        return String.format(Locale.US, "%.1fms", ms);
278    }
279
280    private int calculatePerformanceScore(double throughput, double latencyMs) {
281        // Score based on throughput (0-50 points) and latency (0-50 points)
282        int throughputScore = (int) Math.min(50, throughput / 200);
283        int latencyScore = (int) Math.max(0, 50 - latencyMs / 2);
284        return throughputScore + latencyScore;
285    }
286
287    private String calculatePerformanceGrade(int score) {
288        if (score >= 90) return "A";
289        if (score >= 80) return "B";
290        if (score >= 70) return "C";
291        if (score >= 60) return "D";
292        return "F";
293    }
294}