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.metrics;
017
018import com.google.gson.*;
019import com.google.gson.reflect.TypeToken;
020import de.cuioss.tools.logging.CuiLogger;
021
022import java.io.FileWriter;
023import java.io.IOException;
024import java.lang.reflect.Type;
025import java.nio.file.Files;
026import java.nio.file.Path;
027import java.text.DecimalFormat;
028import java.text.DecimalFormatSymbols;
029import java.time.Instant;
030import java.util.LinkedHashMap;
031import java.util.Locale;
032import java.util.Map;
033import java.util.regex.Matcher;
034import java.util.regex.Pattern;
035
036import static de.cuioss.benchmarking.common.util.BenchmarkingLogMessages.WARN.*;
037
038/**
039 * Exports metrics data to JSON files in a target directory.
040 * Handles JSON serialization, file writing, and metrics aggregation.
041 *
042 */
043public class MetricsJsonExporter {
044
045    private static final CuiLogger LOGGER = new CuiLogger(MetricsJsonExporter.class);
046
047    private static final Gson GSON = new GsonBuilder()
048            .setPrettyPrinting()
049            .serializeNulls()
050            .registerTypeAdapter(Instant.class, (JsonSerializer<Instant>)
051                    (src, typeOfSrc, context) -> new JsonPrimitive(src.toString()))
052            .registerTypeAdapter(Number.class, (JsonSerializer<Number>) (src, typeOfSrc, context) -> {
053                if (src instanceof Double || src instanceof Float) {
054                    double value = src.doubleValue();
055                    if (value == Math.floor(value) && !Double.isInfinite(value)) {
056                        return new JsonPrimitive(src.longValue());
057                    }
058                    return new JsonPrimitive(value);
059                }
060                return new JsonPrimitive(src);
061            })
062            .create();
063    public static final String BEARER_TOKEN_RESULT = "getBearerTokenResult";
064
065    private final Path targetDirectory;
066
067    /**
068     * Creates a new metrics JSON exporter.
069     *
070     * @param targetDirectory Directory where JSON files will be written
071     */
072    public MetricsJsonExporter(Path targetDirectory) {
073        this.targetDirectory = targetDirectory;
074        try {
075            Files.createDirectories(targetDirectory);
076            LOGGER.debug("MetricsJsonExporter initialized with target directory: %s (exists: %s)",
077                    targetDirectory.toAbsolutePath(), Files.exists(targetDirectory));
078        } catch (IOException e) {
079            LOGGER.warn(e, FAILED_CREATE_TARGET_DIR, targetDirectory);
080        }
081    }
082
083    /**
084     * Exports metrics data to a JSON file.
085     *
086     * @param fileName The name of the JSON file
087     * @param metricsData The metrics data to export
088     * @throws IOException if writing fails
089     */
090    public void exportToFile(String fileName, Map<String, Object> metricsData) throws IOException {
091        Path outputFile = targetDirectory.resolve(fileName);
092
093        try (FileWriter writer = new FileWriter(outputFile.toFile())) {
094            GSON.toJson(metricsData, writer);
095            writer.flush();
096            LOGGER.debug("Exported metrics to: %s", outputFile.toAbsolutePath());
097        }
098    }
099
100    /**
101     * Exports JWT validation metrics for a specific benchmark method.
102     *
103     * @param benchmarkMethodName The name of the benchmark method
104     * @param timestamp The timestamp when the benchmark was executed
105     * @param allMetrics All metrics data
106     * @throws IOException if export fails
107     */
108    public void exportJwtValidationMetrics(String benchmarkMethodName, Instant timestamp,
109            Map<String, Double> allMetrics) throws IOException {
110        LOGGER.debug("Exporting JWT validation metrics for: %s", benchmarkMethodName);
111
112        if (isJwtValidationBenchmark(benchmarkMethodName)) {
113            Map<String, Object> timedMetrics = extractTimedMetrics(allMetrics);
114            Map<String, Object> securityEventMetrics = extractSecurityEventMetrics(allMetrics);
115
116            Map<String, Object> benchmarkData = new LinkedHashMap<>();
117            benchmarkData.put("timestamp", timestamp.toString());
118            benchmarkData.put("bearer_token_producer_metrics", timedMetrics);
119            benchmarkData.put("security_event_counter_metrics", securityEventMetrics);
120
121            String simpleBenchmarkName = extractSimpleBenchmarkName(benchmarkMethodName);
122            updateAggregatedMetrics("integration-metrics.json", simpleBenchmarkName, benchmarkData);
123        } else {
124            LOGGER.debug("Benchmark %s is not JWT validation, raw metrics were saved", benchmarkMethodName);
125        }
126    }
127
128    /**
129     * Exports resource metrics (CPU, memory) for system monitoring.
130     *
131     * @param timestamp The timestamp when metrics were collected
132     * @param allMetrics All metrics data
133     * @throws IOException if export fails
134     */
135    public void exportResourceMetrics(Instant timestamp, Map<String, Double> allMetrics) throws IOException {
136        LOGGER.debug("Exporting resource metrics");
137
138        Map<String, Object> resourceData = new LinkedHashMap<>();
139        resourceData.put("timestamp", timestamp.toString());
140        resourceData.put("cpu_metrics", extractCpuMetrics(allMetrics));
141        resourceData.put("memory_metrics", extractMemoryMetrics(allMetrics));
142
143        exportToFile("resource-metrics.json", resourceData);
144    }
145
146    /**
147     * Updates an aggregated metrics file with new benchmark data.
148     * For quarkus-metrics.json files, applies special transformation to create the new structure.
149     *
150     * @param fileName The name of the aggregated metrics file
151     * @param benchmarkName The name of the benchmark
152     * @param benchmarkData The benchmark data to add/update
153     * @throws IOException if writing fails
154     */
155    public void updateAggregatedMetrics(String fileName, String benchmarkName,
156            Map<String, Object> benchmarkData) throws IOException {
157        Map<String, Object> allMetrics = readExistingMetrics(fileName);
158
159        if ("quarkus-metrics.json".equals(fileName)) {
160            // Apply special transformation for Quarkus runtime metrics
161            String transformedKey = "quarkus-runtime-metrics";
162            Map<String, Object> transformedData = transformToQuarkusRuntimeMetrics(benchmarkData);
163            allMetrics.put(transformedKey, transformedData);
164        } else {
165            allMetrics.put(benchmarkName, benchmarkData);
166        }
167
168        exportToFile(fileName, allMetrics);
169        LOGGER.debug("Updated %s with %s benchmarks", fileName, allMetrics.size());
170    }
171
172    /**
173     * Transforms benchmark data into the new Quarkus runtime metrics structure.
174     * Removes the "benchmark" field and ensures proper structure.
175     */
176    private Map<String, Object> transformToQuarkusRuntimeMetrics(Map<String, Object> originalData) {
177        Map<String, Object> transformed = new LinkedHashMap<>(originalData);
178
179        // Remove the "benchmark" field if present
180        transformed.remove("benchmark");
181
182        return transformed;
183    }
184
185    /**
186     * Reads existing metrics from a JSON file.
187     *
188     * @param fileName The name of the JSON file
189     * @return The parsed metrics map, or empty map if file doesn't exist or is empty
190     */
191    public Map<String, Object> readExistingMetrics(String fileName) {
192        Map<String, Object> existingMetrics = new LinkedHashMap<>();
193        Path filePath = targetDirectory.resolve(fileName);
194
195        if (Files.exists(filePath)) {
196            try {
197                String content = Files.readString(filePath);
198                if (!content.trim().isEmpty()) {
199                    Type mapType = new TypeToken<Map<String, Object>>(){
200                    }.getType();
201                    Map<String, Object> parsed = GSON.fromJson(content, mapType);
202                    if (parsed != null) {
203                        existingMetrics = parsed;
204                    }
205                }
206            } catch (IOException | JsonSyntaxException e) {
207                LOGGER.warn(FAILED_READ_METRICS_FILE, fileName, e.getMessage());
208                try {
209                    Files.deleteIfExists(filePath);
210                } catch (IOException deleteException) {
211                    LOGGER.warn(deleteException, FAILED_DELETE_CORRUPTED_FILE);
212                }
213            }
214        }
215
216        return existingMetrics;
217    }
218
219    private boolean isJwtValidationBenchmark(String benchmarkMethodName) {
220        return benchmarkMethodName.contains("JwtValidationBenchmark") ||
221                "JwtValidation".equals(benchmarkMethodName) ||
222                benchmarkMethodName.startsWith("validateJwt") ||
223                "validateJwtToken".equals(benchmarkMethodName) ||
224                benchmarkMethodName.contains("validateAccessToken") ||
225                benchmarkMethodName.contains("validateIdToken");
226    }
227
228    private Map<String, Object> extractTimedMetrics(Map<String, Double> allMetrics) {
229        Map<String, Object> timedMetrics = new LinkedHashMap<>();
230
231        Double count = null;
232        Double sum = null;
233        Double max = null;
234
235        for (Map.Entry<String, Double> entry : allMetrics.entrySet()) {
236            String metricName = entry.getKey();
237            Double value = entry.getValue();
238
239            if (metricName.contains("bearer_token_validation_seconds")) {
240                if (metricName.contains("_count") && metricName.contains(BEARER_TOKEN_RESULT)) {
241                    count = value;
242                } else if (metricName.contains("_sum") && metricName.contains(BEARER_TOKEN_RESULT)) {
243                    sum = value;
244                } else if (metricName.contains("_max") && metricName.contains(BEARER_TOKEN_RESULT)) {
245                    max = value;
246                }
247            }
248        }
249
250        if (count != null && sum != null && max != null && count > 0) {
251            double avgMicros = (sum / count) * 1_000_000;
252
253            Map<String, Object> validationMetric = new LinkedHashMap<>();
254            validationMetric.put("sample_count", formatNumber(count.longValue()));
255            validationMetric.put("p50_us", formatNumber(avgMicros));
256            validationMetric.put("p95_us", formatNumber(Math.min(avgMicros * 2, max * 1_000_000 * 0.8)));
257            validationMetric.put("p99_us", formatNumber(max * 1_000_000 * 0.9));
258            timedMetrics.put("validation", validationMetric);
259        } else {
260            Map<String, Object> emptyMetric = new LinkedHashMap<>();
261            emptyMetric.put("sample_count", formatNumber(0));
262            emptyMetric.put("p50_us", formatNumber(0));
263            emptyMetric.put("p95_us", formatNumber(0));
264            emptyMetric.put("p99_us", formatNumber(0));
265            timedMetrics.put("validation", emptyMetric);
266        }
267
268        return timedMetrics;
269    }
270
271    private Map<String, Object> extractSecurityEventMetrics(Map<String, Double> allMetrics) {
272        Map<String, Object> securityMetrics = new LinkedHashMap<>();
273        Map<String, Map<String, Object>> errorsByCategory = new LinkedHashMap<>();
274        Map<String, Object> successByType = new LinkedHashMap<>();
275        long totalErrors = 0;
276        long totalSuccess = 0;
277
278        for (Map.Entry<String, Double> entry : allMetrics.entrySet()) {
279            String metricName = entry.getKey();
280            Double value = entry.getValue();
281
282            if (metricName.startsWith("sheriff_oauth_validation_errors_total")) {
283                String category = extractTag(metricName, "category");
284                String eventType = extractTag(metricName, "event_type");
285
286                if (category != null && eventType != null && value != null && value > 0) {
287                    Map<String, Object> categoryData = errorsByCategory.computeIfAbsent(category, k -> new LinkedHashMap<>());
288                    categoryData.put(eventType, formatNumber(value.longValue()));
289                    totalErrors += value.longValue();
290                }
291            } else if (metricName.startsWith("sheriff_oauth_validation_success")) {
292                String eventType = extractTag(metricName, "event_type");
293                String result = extractTag(metricName, "result");
294
295                if (eventType != null && "success".equals(result) && value != null && value > 0) {
296                    successByType.put(eventType, formatNumber(value.longValue()));
297                    totalSuccess += value.longValue();
298                }
299            }
300        }
301
302        securityMetrics.put("total_errors", formatNumber(totalErrors));
303        securityMetrics.put("total_success", formatNumber(totalSuccess));
304        securityMetrics.put("errors_by_category", errorsByCategory);
305        securityMetrics.put("success_by_type", successByType);
306
307        return securityMetrics;
308    }
309
310    private Map<String, Object> extractCpuMetrics(Map<String, Double> allMetrics) {
311        Map<String, Object> cpuMetrics = new LinkedHashMap<>();
312
313        for (Map.Entry<String, Double> entry : allMetrics.entrySet()) {
314            String metricName = entry.getKey();
315            Double value = entry.getValue();
316
317            if (metricName.startsWith("system_cpu_usage")) {
318                cpuMetrics.put("system_cpu_usage", formatPercentage(value));
319            } else if (metricName.startsWith("process_cpu_usage")) {
320                cpuMetrics.put("process_cpu_usage", formatPercentage(value));
321            } else if (metricName.startsWith("system_cpu_count")) {
322                cpuMetrics.put("cpu_count", value.intValue());
323            }
324        }
325
326        return cpuMetrics;
327    }
328
329    private Map<String, Object> extractMemoryMetrics(Map<String, Double> allMetrics) {
330        Map<String, Object> memoryMetrics = new LinkedHashMap<>();
331
332        for (Map.Entry<String, Double> entry : allMetrics.entrySet()) {
333            String metricName = entry.getKey();
334            Double value = entry.getValue();
335
336            if (metricName.startsWith("jvm_memory_used_bytes")) {
337                String area = extractTag(metricName, "area");
338                if ("heap".equals(area)) {
339                    memoryMetrics.put("heap_used_bytes", value.longValue());
340                } else if ("nonheap".equals(area)) {
341                    memoryMetrics.put("nonheap_used_bytes", value.longValue());
342                }
343            }
344        }
345
346        return memoryMetrics;
347    }
348
349    private String extractTag(String metricName, String tagName) {
350        String pattern = tagName + "=\"([^\"]+)\"";
351        Pattern regex = Pattern.compile(pattern);
352        Matcher matcher = regex.matcher(metricName);
353        return matcher.find() ? matcher.group(1) : null;
354    }
355
356    private Object formatNumber(double value) {
357        if (value < 10) {
358            DecimalFormat df = new DecimalFormat("0.0", DecimalFormatSymbols.getInstance(Locale.US));
359            return Double.parseDouble(df.format(value));
360        } else {
361            return Math.round(value);
362        }
363    }
364
365    private Object formatPercentage(double value) {
366        double percentage = value * 100.0;
367        if (percentage < 10) {
368            return Math.round(percentage * 10.0) / 10.0;
369        } else {
370            return Math.round(percentage);
371        }
372    }
373
374    private String extractSimpleBenchmarkName(String fullBenchmarkName) {
375        if (fullBenchmarkName.contains(".")) {
376            return fullBenchmarkName.substring(fullBenchmarkName.lastIndexOf('.') + 1);
377        }
378        return fullBenchmarkName;
379    }
380}