diff options
author | gjoranv <gv@verizonmedia.com> | 2019-08-27 11:44:41 +0200 |
---|---|---|
committer | gjoranv <gv@verizonmedia.com> | 2019-08-27 16:21:01 +0200 |
commit | 3007317e7fcfc0bc11e41f878bb7e2a98bd9fbfc (patch) | |
tree | 6c99a1e82194399005a71493b46654bd5d1973d9 | |
parent | b6a3f24a4a34002d1d49246d739921cc23e33564 (diff) |
Add Prometheus http api
13 files changed, 369 insertions, 7 deletions
diff --git a/metrics-proxy/pom.xml b/metrics-proxy/pom.xml index 26caa3f66af..99354ee22e8 100644 --- a/metrics-proxy/pom.xml +++ b/metrics-proxy/pom.xml @@ -116,6 +116,14 @@ <version>${project.version}</version> </dependency> <dependency> + <groupId>io.prometheus</groupId> + <artifactId>simpleclient</artifactId> + </dependency> + <dependency> + <groupId>io.prometheus</groupId> + <artifactId>simpleclient_common</artifactId> + </dependency> + <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</artifactId> </dependency> diff --git a/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/ErrorResponse.java b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/ErrorResponse.java index daa69191506..d3296331a6a 100644 --- a/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/ErrorResponse.java +++ b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/ErrorResponse.java @@ -15,12 +15,12 @@ import static java.util.logging.Level.WARNING; /** * @author gjoranv */ -class ErrorResponse extends JsonResponse { +public class ErrorResponse extends JsonResponse { private static Logger log = Logger.getLogger(ErrorResponse.class.getName()); private static ObjectMapper objectMapper = new ObjectMapper(); - ErrorResponse(int code, String message) { + public ErrorResponse(int code, String message) { super(code, asErrorJson(message)); } diff --git a/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/JsonResponse.java b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/JsonResponse.java index 758a043a823..9f31e9356e8 100644 --- a/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/JsonResponse.java +++ b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/JsonResponse.java @@ -13,10 +13,10 @@ import java.nio.charset.Charset; /** * @author gjoranv */ -class JsonResponse extends HttpResponse { +public class JsonResponse extends HttpResponse { private final byte[] data; - JsonResponse(int code, String data) { + public JsonResponse(int code, String data) { super(code); this.data = data.getBytes(Charset.forName(DEFAULT_CHARACTER_ENCODING)); } diff --git a/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/RestApiUtil.java b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/RestApiUtil.java index 27411b5dbd9..9d9256e17e8 100644 --- a/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/RestApiUtil.java +++ b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/RestApiUtil.java @@ -23,7 +23,7 @@ public class RestApiUtil { private static Logger log = Logger.getLogger(RestApiUtil.class.getName()); - static JsonResponse resourceListResponse(URI requestUri, List<String> resources) { + public static JsonResponse resourceListResponse(URI requestUri, List<String> resources) { try { return new JsonResponse(OK, RestApiUtil.resourceList(requestUri, resources)); } catch (JSONException e) { diff --git a/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/TextResponse.java b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/TextResponse.java new file mode 100644 index 00000000000..7dfc232d253 --- /dev/null +++ b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/TextResponse.java @@ -0,0 +1,31 @@ +/* + * Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + */ + +package ai.vespa.metricsproxy.http; + +import com.yahoo.container.jdisc.HttpResponse; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.Charset; + +/** + * @author yj-jtakagi + * @author gjoranv + */ +public class TextResponse extends HttpResponse { + + private final byte[] data; + + public TextResponse(int code, String data) { + super(code); + this.data = data.getBytes(Charset.forName(DEFAULT_CHARACTER_ENCODING)); + } + + @Override + public void render(OutputStream outputStream) throws IOException { + outputStream.write(data); + } + +} diff --git a/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/ValuesFetcher.java b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/ValuesFetcher.java index f99fe962f38..00f2078ef57 100644 --- a/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/ValuesFetcher.java +++ b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/ValuesFetcher.java @@ -33,7 +33,7 @@ public class ValuesFetcher { private final VespaServices vespaServices; private final MetricsConsumers metricsConsumers; - ValuesFetcher(MetricsManager metricsManager, + public ValuesFetcher(MetricsManager metricsManager, VespaServices vespaServices, MetricsConsumers metricsConsumers) { this.metricsManager = metricsManager; diff --git a/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/prometheus/PrometheusHandler.java b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/prometheus/PrometheusHandler.java new file mode 100644 index 00000000000..7c018ea6d85 --- /dev/null +++ b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/http/prometheus/PrometheusHandler.java @@ -0,0 +1,73 @@ +/* + * Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + */ + +package ai.vespa.metricsproxy.http.prometheus; + +import ai.vespa.metricsproxy.core.MetricsConsumers; +import ai.vespa.metricsproxy.core.MetricsManager; +import ai.vespa.metricsproxy.http.ErrorResponse; +import ai.vespa.metricsproxy.http.JsonResponse; +import ai.vespa.metricsproxy.http.TextResponse; +import ai.vespa.metricsproxy.http.ValuesFetcher; +import ai.vespa.metricsproxy.metric.model.MetricsPacket; +import ai.vespa.metricsproxy.metric.model.json.JsonRenderingException; +import ai.vespa.metricsproxy.service.VespaServices; +import com.google.inject.Inject; +import com.yahoo.container.jdisc.HttpRequest; +import com.yahoo.container.jdisc.HttpResponse; +import com.yahoo.container.jdisc.ThreadedHttpRequestHandler; +import com.yahoo.restapi.Path; + +import java.util.List; +import java.util.concurrent.Executor; + +import static ai.vespa.metricsproxy.http.RestApiUtil.resourceListResponse; +import static ai.vespa.metricsproxy.metric.model.prometheus.PrometheusUtil.toPrometheusModel; +import static com.yahoo.jdisc.Response.Status.INTERNAL_SERVER_ERROR; +import static com.yahoo.jdisc.Response.Status.METHOD_NOT_ALLOWED; +import static com.yahoo.jdisc.Response.Status.NOT_FOUND; +import static com.yahoo.jdisc.Response.Status.OK; +import static com.yahoo.jdisc.http.HttpRequest.Method.GET; + +/** + * @author gjoranv + */ +public class PrometheusHandler extends ThreadedHttpRequestHandler { + + static final String V1_PATH = "/prometheus/v1"; + static final String VALUES_PATH = V1_PATH + "/values"; + + private final ValuesFetcher valuesFetcher; + + @Inject + public PrometheusHandler(Executor executor, + MetricsManager metricsManager, + VespaServices vespaServices, + MetricsConsumers metricsConsumers) { + super(executor); + valuesFetcher = new ValuesFetcher(metricsManager, vespaServices, metricsConsumers); + } + + @Override + public HttpResponse handle(HttpRequest request) { + if (request.getMethod() != GET) return new JsonResponse(METHOD_NOT_ALLOWED, "Only GET is supported"); + + Path path = new Path(request.getUri()); + + if (path.matches(V1_PATH)) return resourceListResponse(request.getUri(), List.of(VALUES_PATH)); + if (path.matches(VALUES_PATH)) return valuesResponse(request); + + return new ErrorResponse(NOT_FOUND, "No content at given path"); + } + + private TextResponse valuesResponse(HttpRequest request) { + try { + List<MetricsPacket> metrics = valuesFetcher.fetch(request.getProperty("consumer")); + return new TextResponse(OK, toPrometheusModel(metrics).serialize()); + } catch (JsonRenderingException e) { + return new TextResponse(INTERNAL_SERVER_ERROR, e.getMessage()); + } + } + +} diff --git a/metrics-proxy/src/main/java/ai/vespa/metricsproxy/metric/model/prometheus/PrometheusModel.java b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/metric/model/prometheus/PrometheusModel.java new file mode 100644 index 00000000000..83119a552f1 --- /dev/null +++ b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/metric/model/prometheus/PrometheusModel.java @@ -0,0 +1,52 @@ +/* + * Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + */ + +package ai.vespa.metricsproxy.metric.model.prometheus; + +import io.prometheus.client.Collector; +import io.prometheus.client.exporter.common.TextFormat; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.Enumeration; +import java.util.Iterator; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * @author yj-jtakagi + * @author gjoranv + */ +public class PrometheusModel implements Enumeration<Collector.MetricFamilySamples> { + private static Logger log = Logger.getLogger(PrometheusModel.class.getName()); + + private final Iterator<Collector.MetricFamilySamples> metricFamilySamplesIterator; + + PrometheusModel(List<Collector.MetricFamilySamples> metricFamilySamples) { + this.metricFamilySamplesIterator = metricFamilySamples.iterator(); + } + + @Override + public boolean hasMoreElements() { + return metricFamilySamplesIterator.hasNext(); + } + + @Override + public Collector.MetricFamilySamples nextElement() { + return metricFamilySamplesIterator.next(); + } + + public String serialize() { + var writer = new StringWriter(); + try { + TextFormat.write004(writer, this); + } catch (IOException e) { + log.log(Level.WARNING, "Got exception when rendering metrics:", e); + throw new PrometheusRenderingException("Could not render metrics. Check the log for details."); + } + return writer.toString(); + } + +} diff --git a/metrics-proxy/src/main/java/ai/vespa/metricsproxy/metric/model/prometheus/PrometheusRenderingException.java b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/metric/model/prometheus/PrometheusRenderingException.java new file mode 100644 index 00000000000..68db7e64bfd --- /dev/null +++ b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/metric/model/prometheus/PrometheusRenderingException.java @@ -0,0 +1,16 @@ +/* + * Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + */ + +package ai.vespa.metricsproxy.metric.model.prometheus; + +/** + * @author gjoranv + */ +public class PrometheusRenderingException extends RuntimeException { + + PrometheusRenderingException(String message) { + super(message); + } + +} diff --git a/metrics-proxy/src/main/java/ai/vespa/metricsproxy/metric/model/prometheus/PrometheusUtil.java b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/metric/model/prometheus/PrometheusUtil.java new file mode 100644 index 00000000000..cbd4ad2ef8d --- /dev/null +++ b/metrics-proxy/src/main/java/ai/vespa/metricsproxy/metric/model/prometheus/PrometheusUtil.java @@ -0,0 +1,76 @@ +/* + * Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + */ + +package ai.vespa.metricsproxy.metric.model.prometheus; + +import ai.vespa.metricsproxy.metric.model.MetricsPacket; +import ai.vespa.metricsproxy.metric.model.ServiceId; +import io.prometheus.client.Collector; +import io.prometheus.client.Collector.MetricFamilySamples; +import io.prometheus.client.Collector.MetricFamilySamples.Sample; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; + +/** + * @author yj-jtakagi + * @author gjoranv + */ +public class PrometheusUtil { + + public static PrometheusModel toPrometheusModel(List<MetricsPacket> metricsPackets) { + Map<ServiceId, List<MetricsPacket>> packetsByService = metricsPackets.stream() + .collect(Collectors.groupingBy(packet -> packet.service)); + + List<MetricFamilySamples> metricFamilySamples = new ArrayList<>(packetsByService.size()); + + packetsByService.forEach(((serviceId, packets) -> { + Map<String, List<Sample>> samples = new HashMap<>(); + + var serviceName = Collector.sanitizeMetricName(serviceId.id); + for (var packet : packets) { + var dimensions = packet.dimensions(); + List<String> labels = new ArrayList<>(dimensions.size()); + List<String> labelValues = new ArrayList<>(dimensions.size()); + for (var entry : dimensions.entrySet()) { + var labelName = Collector.sanitizeMetricName(entry.getKey().id); + labels.add(labelName); + labelValues.add(entry.getValue()); + } + + for (var metric : packet.metrics().entrySet()) { + var metricName = serviceName + "_" + Collector.sanitizeMetricName(metric.getKey().id); + List<Sample> sampleList; + if (samples.containsKey(metricName)) { + sampleList = samples.get(metricName); + } else { + sampleList = new ArrayList<>(); + samples.put(metricName, sampleList); + metricFamilySamples.add(new MetricFamilySamples(metricName, Collector.Type.UNTYPED, "", sampleList)); + } + sampleList.add(new Sample(metricName, labels, labelValues, metric.getValue().doubleValue(), packet.timestamp * 1000)); + } + } + // convert status message to 0,1 metric + var firstPacket = packets.get(0); + String statusMetricName = serviceName + "_status"; + // MetricsPacket status 0 means OK, but it's the opposite in Prometheus. + double statusMetricValue = (firstPacket.statusCode == 0) ? 1.0 : 0.0; + List<Sample> sampleList = singletonList(new Sample(statusMetricName, emptyList(), emptyList(), + statusMetricValue, firstPacket.timestamp * 1000)); + metricFamilySamples.add(new MetricFamilySamples(statusMetricName, Collector.Type.UNTYPED, "status of service", sampleList)); + })); + + return new PrometheusModel(metricFamilySamples); + } + +} diff --git a/metrics-proxy/src/test/java/ai/vespa/metricsproxy/http/prometheus/PrometheusHandlerTest.java b/metrics-proxy/src/test/java/ai/vespa/metricsproxy/http/prometheus/PrometheusHandlerTest.java new file mode 100644 index 00000000000..d0ce2837568 --- /dev/null +++ b/metrics-proxy/src/test/java/ai/vespa/metricsproxy/http/prometheus/PrometheusHandlerTest.java @@ -0,0 +1,95 @@ +/* + * Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. + */ + +package ai.vespa.metricsproxy.http.prometheus; + +import ai.vespa.metricsproxy.http.HttpHandlerTestBase; +import ai.vespa.metricsproxy.service.DummyService; +import com.yahoo.container.jdisc.RequestHandlerTestDriver; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.BeforeClass; +import org.junit.Ignore; +import org.junit.Test; + +import java.util.concurrent.Executors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * @author gjoranv + */ +@SuppressWarnings("UnstableApiUsage") +public class PrometheusHandlerTest extends HttpHandlerTestBase { + + private static final String V1_URI = URI_BASE + PrometheusHandler.V1_PATH; + private static final String VALUES_URI = URI_BASE + PrometheusHandler.VALUES_PATH; + + private static String valuesResponse; + + @BeforeClass + public static void setup() { + PrometheusHandler handler = new PrometheusHandler(Executors.newSingleThreadExecutor(), + getMetricsManager(), + vespaServices, + getMetricsConsumers()); + testDriver = new RequestHandlerTestDriver(handler); + valuesResponse = testDriver.sendRequest(VALUES_URI).readAll(); + } + + @Test + public void v1_response_contains_values_uri() throws Exception { + String response = testDriver.sendRequest(V1_URI).readAll(); + JSONObject root = new JSONObject(response); + assertTrue(root.has("resources")); + + JSONArray resources = root.getJSONArray("resources"); + assertEquals(1, resources.length()); + + JSONObject valuesUrl = resources.getJSONObject(0); + assertEquals(VALUES_URI, valuesUrl.getString("url")); + } + + @Ignore + @Test + public void visually_inspect_values_response() { + System.out.println(valuesResponse); + } + + @Test + public void response_contains_node_status() { + assertTrue(valuesResponse.contains("vespa_node_status 1.0")); + } + + @Test + public void response_contains_node_metrics() { + String cpu = getLine(valuesResponse, CPU_METRIC + "{"); + assertTrue(cpu.contains("} 12.345")); // metric value + assertTrue(cpu.contains("{vespaVersion=")); + } + + @Test + public void response_contains_service_status() { + assertTrue(valuesResponse.contains("vespa_dummy_status 1.0")); + assertTrue(valuesResponse.contains("vespa_down_service_status 0.0")); + } + + @Test + public void response_contains_service_metrics() { + String dummy0 = getLine(valuesResponse, DummyService.NAME + "0"); + assertTrue(dummy0.contains("c_test")); // metric name + assertTrue(dummy0.contains("} 1.0")); // metric value + assertTrue(dummy0.contains("consumer_dim=\"default-val\"")); + } + + // Find the first line that contains the given string + private String getLine(String raw, String searchString) { + for (var s : raw.split("\\n")) { + if (s.contains(searchString)) + return s; + } + throw new IllegalArgumentException("No line containing string: " + searchString); + } +} diff --git a/metrics-proxy/src/test/java/ai/vespa/metricsproxy/service/DummyService.java b/metrics-proxy/src/test/java/ai/vespa/metricsproxy/service/DummyService.java index 380a992aead..cf559628490 100644 --- a/metrics-proxy/src/test/java/ai/vespa/metricsproxy/service/DummyService.java +++ b/metrics-proxy/src/test/java/ai/vespa/metricsproxy/service/DummyService.java @@ -11,7 +11,7 @@ import ai.vespa.metricsproxy.metric.Metrics; * @author Unknown */ public class DummyService extends VespaService { - static final String NAME = "dummy"; + public static final String NAME = "dummy"; public static final String METRIC_1 = "c.test"; public static final String METRIC_2 = "val"; diff --git a/parent/pom.xml b/parent/pom.xml index a81f64d1725..1b22bcefb18 100644 --- a/parent/pom.xml +++ b/parent/pom.xml @@ -436,6 +436,16 @@ <version>0.7</version> </dependency> <dependency> + <groupId>io.prometheus</groupId> + <artifactId>simpleclient</artifactId> + <version>${prometheus.client.version}</version> + </dependency> + <dependency> + <groupId>io.prometheus</groupId> + <artifactId>simpleclient_common</artifactId> + <version>${prometheus.client.version}</version> + </dependency> + <dependency> <groupId>org.ow2.asm</groupId> <artifactId>asm</artifactId> <version>${asm.version}</version> @@ -777,6 +787,7 @@ <doclint>all</doclint> <surefire.version>2.22.0</surefire.version> <junit.version>5.4.2</junit.version> + <prometheus.client.version>0.6.0</prometheus.client.version> <protobuf.version>3.7.0</protobuf.version> </properties> |