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}