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.Gson; 019import com.google.gson.JsonArray; 020import com.google.gson.JsonElement; 021import com.google.gson.JsonObject; 022import com.google.gson.JsonParseException; 023import de.cuioss.benchmarking.common.http.HttpClientFactory; 024import de.cuioss.tools.logging.CuiLogger; 025import lombok.Getter; 026 027import java.io.IOException; 028import java.net.URI; 029import java.net.URLEncoder; 030import java.net.http.HttpClient; 031import java.net.http.HttpRequest; 032import java.net.http.HttpResponse; 033import java.nio.charset.StandardCharsets; 034import java.time.Duration; 035import java.time.Instant; 036import java.util.ArrayList; 037import java.util.HashMap; 038import java.util.List; 039import java.util.Map; 040 041import static de.cuioss.benchmarking.common.util.BenchmarkingLogMessages.WARN.FAILED_QUERY_METRIC; 042 043/** 044 * HTTP client for querying Prometheus API endpoints. 045 * Supports query_range operations for time-series data retrieval during benchmark execution. 046 * 047 */ 048public class PrometheusClient { 049 050 private static final CuiLogger LOGGER = new CuiLogger(PrometheusClient.class); 051 private static final int HTTP_OK = 200; 052 private static final Gson GSON = new Gson(); 053 054 private final String prometheusUrl; 055 private final Duration timeout; 056 private final HttpClient httpClient; 057 058 /** 059 * Creates a new Prometheus client with default timeout. 060 * 061 * @param prometheusUrl Base URL of Prometheus server (e.g., {@code http://localhost:9090}) 062 */ 063 public PrometheusClient(String prometheusUrl) { 064 this(prometheusUrl, Duration.ofSeconds(30)); 065 } 066 067 /** 068 * Creates a new Prometheus client with custom timeout. 069 * 070 * @param prometheusUrl Base URL of Prometheus server 071 * @param timeout HTTP request timeout 072 */ 073 public PrometheusClient(String prometheusUrl, Duration timeout) { 074 this.prometheusUrl = prometheusUrl.endsWith("/") ? prometheusUrl.substring(0, prometheusUrl.length() - 1) : prometheusUrl; 075 this.timeout = timeout; 076 this.httpClient = HttpClientFactory.getInsecureClient(); 077 } 078 079 /** 080 * Query Prometheus for time-series data in a specific time range. 081 * 082 * @param metricNames List of metric names to query 083 * @param startTime Start of time range (inclusive) 084 * @param endTime End of time range (inclusive) 085 * @param step Step size between data points 086 * @return Map of metric name to time series data 087 * @throws PrometheusException if query fails or Prometheus unavailable 088 */ 089 public Map<String, TimeSeries> queryRange(List<String> metricNames, Instant startTime, Instant endTime, Duration step) throws PrometheusException { 090 Map<String, TimeSeries> results = new HashMap<>(); 091 092 for (String metricName : metricNames) { 093 try { 094 TimeSeries timeSeries = queryRangeForMetric(metricName, startTime, endTime, step); 095 results.put(metricName, timeSeries); 096 } catch (PrometheusException e) { 097 LOGGER.warn(FAILED_QUERY_METRIC, metricName, e.getMessage()); 098 throw e; 099 } 100 } 101 102 return results; 103 } 104 105 private TimeSeries queryRangeForMetric(String metricName, Instant startTime, Instant endTime, Duration step) throws PrometheusException { 106 String stepSeconds = step.getSeconds() + "s"; 107 String startEpoch = String.valueOf(startTime.getEpochSecond()); 108 String endEpoch = String.valueOf(endTime.getEpochSecond()); 109 110 String queryUrl = "%s/api/v1/query_range?query=%s&start=%s&end=%s&step=%s".formatted( 111 prometheusUrl, 112 URLEncoder.encode(metricName, StandardCharsets.UTF_8), 113 startEpoch, 114 endEpoch, 115 stepSeconds); 116 117 HttpRequest request = HttpRequest.newBuilder() 118 .uri(URI.create(queryUrl)) 119 .timeout(timeout) 120 .GET() 121 .build(); 122 123 try { 124 HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); 125 126 if (response.statusCode() != HTTP_OK) { 127 throw new PrometheusException( 128 response.statusCode(), 129 "HTTP %d from Prometheus API".formatted(response.statusCode()) 130 ); 131 } 132 133 return parsePrometheusResponse(metricName, response.body()); 134 135 } catch (IOException e) { 136 throw new PrometheusException("Network error connecting to Prometheus: " + e.getMessage(), e); 137 } catch (InterruptedException e) { 138 Thread.currentThread().interrupt(); 139 throw new PrometheusException("Request interrupted", e); 140 } 141 } 142 143 private TimeSeries parsePrometheusResponse(String metricName, String responseBody) throws PrometheusException { 144 try { 145 JsonObject root = GSON.fromJson(responseBody, JsonObject.class); 146 validateStatus(root); 147 148 JsonObject data = root.getAsJsonObject("data"); 149 JsonArray resultArray = data.getAsJsonArray("result"); 150 151 if (resultArray == null || resultArray.isEmpty()) { 152 // Metric may not be exported by the application - return empty series silently 153 return new TimeSeries(metricName, Map.of(), List.of()); 154 } 155 156 JsonObject firstResult = resultArray.get(0).getAsJsonObject(); 157 Map<String, String> labels = extractLabels(firstResult.getAsJsonObject("metric")); 158 List<DataPoint> dataPoints = extractDataPoints(firstResult.getAsJsonArray("values")); 159 160 return new TimeSeries(metricName, labels, dataPoints); 161 162 } catch (JsonParseException | IllegalStateException | NullPointerException e) { 163 // JsonParseException: malformed JSON 164 // IllegalStateException: JSON element is wrong type (e.g., not an object/array when expected) 165 // NullPointerException: missing expected JSON fields 166 throw new PrometheusException("Unexpected error parsing response: " + e.getMessage(), e); 167 } 168 } 169 170 private void validateStatus(JsonObject root) throws PrometheusException { 171 JsonElement statusElement = root.get("status"); 172 if (statusElement == null || !"success".equals(statusElement.getAsString())) { 173 JsonElement errorElement = root.get("error"); 174 String error = errorElement != null ? errorElement.getAsString() : "Unknown error"; 175 throw new PrometheusException("Prometheus query failed: " + error); 176 } 177 } 178 179 private Map<String, String> extractLabels(JsonObject metric) { 180 Map<String, String> labels = new HashMap<>(); 181 if (metric != null) { 182 for (Map.Entry<String, JsonElement> entry : metric.entrySet()) { 183 labels.put(entry.getKey(), entry.getValue().getAsString()); 184 } 185 } 186 return labels; 187 } 188 189 private List<DataPoint> extractDataPoints(JsonArray values) { 190 List<DataPoint> dataPoints = new ArrayList<>(); 191 if (values != null) { 192 for (JsonElement valueElement : values) { 193 if (valueElement.isJsonArray()) { 194 JsonArray valueArray = valueElement.getAsJsonArray(); 195 if (valueArray.size() >= 2) { 196 long timestamp = valueArray.get(0).getAsLong(); 197 double val = Double.parseDouble(valueArray.get(1).getAsString()); 198 dataPoints.add(new DataPoint(Instant.ofEpochSecond(timestamp), val)); 199 } 200 } 201 } 202 } 203 return dataPoints; 204 } 205 206 /** 207 * Represents time-series data for a metric. 208 * 209 * @param metricName name of the metric 210 * @param labels metric labels as key-value pairs 211 * @param values list of data points 212 */ 213 public record TimeSeries(String metricName, Map<String, String> labels, List<DataPoint> values) { 214 public TimeSeries { 215 labels = Map.copyOf(labels); 216 values = List.copyOf(values); 217 } 218 } 219 220 /** 221 * Represents a single data point in time-series data. 222 * 223 * @param timestamp point in time for this measurement 224 * @param value measured value at this timestamp 225 */ 226 public record DataPoint(Instant timestamp, double value) { 227 } 228 229 /** 230 * Exception thrown when Prometheus operations fail. 231 */ 232 @Getter 233 public static class PrometheusException extends Exception { 234 private final int statusCode; 235 236 public PrometheusException(String message) { 237 this(message, null); 238 } 239 240 public PrometheusException(String message, Throwable cause) { 241 super(message, cause); 242 this.statusCode = -1; 243 } 244 245 public PrometheusException(int statusCode, String message) { 246 super(message); 247 this.statusCode = statusCode; 248 } 249 } 250}