diff --git a/common/http/src/main/java/io/helidon/common/http/MediaType.java b/common/http/src/main/java/io/helidon/common/http/MediaType.java index ccdbaf2c387..bd267b088c3 100644 --- a/common/http/src/main/java/io/helidon/common/http/MediaType.java +++ b/common/http/src/main/java/io/helidon/common/http/MediaType.java @@ -152,6 +152,11 @@ public final class MediaType implements AcceptPredicate { */ public static final MediaType APPLICATION_X_NDJSON; + /** + * A {@link MediaType} constant representing the {@code application/openmetrics-text} media type. + */ + public static final MediaType APPLICATION_OPENMETRICS; + static { Map knownTypes = new HashMap<>(); @@ -221,6 +226,9 @@ public final class MediaType implements AcceptPredicate { APPLICATION_X_NDJSON = new MediaType("application", "x-ndjson"); knownTypes.put("application/x-ndjson", APPLICATION_X_NDJSON); + APPLICATION_OPENMETRICS = new MediaType("application", "openmetrics-text"); + knownTypes.put("application/openmetrics-text", APPLICATION_OPENMETRICS); + KNOWN_TYPES = Collections.unmodifiableMap(knownTypes); } diff --git a/metrics/metrics/src/main/java/io/helidon/metrics/HelidonSimpleTimer.java b/metrics/metrics/src/main/java/io/helidon/metrics/HelidonSimpleTimer.java index 71b6790a597..3cfa5728c6d 100644 --- a/metrics/metrics/src/main/java/io/helidon/metrics/HelidonSimpleTimer.java +++ b/metrics/metrics/src/main/java/io/helidon/metrics/HelidonSimpleTimer.java @@ -101,7 +101,7 @@ public void prometheusData(StringBuilder sb, MetricID metricID, boolean withHelp SimpleTimerImpl simpleTimerImpl = (delegate instanceof SimpleTimerImpl) ? ((SimpleTimerImpl) delegate) : null; Sample.Labeled sample = simpleTimerImpl != null ? simpleTimerImpl.sample : null; if (sample != null) { - sb.append(prometheusExemplar(elapsedTimeInSeconds(sample.value()), simpleTimerImpl.sample)); + sb.append(prometheusExemplar(1, sample)); // exemplar always contributes 1 to the count } sb.append("\n"); @@ -113,11 +113,9 @@ public void prometheusData(StringBuilder sb, MetricID metricID, boolean withHelp sb.append(promName) .append(tags) .append(" ") - .append(elapsedTimeInSeconds()); - if (sample != null) { - sb.append(prometheusExemplar(elapsedTimeInSeconds(sample.value()), sample)); - } - sb.append("\n"); + .append(elapsedTimeInSeconds()) + .append(exemplarForElapsedTime(sample)) + .append("\n"); } @Override @@ -135,6 +133,10 @@ public void jsonData(JsonObjectBuilder builder, MetricID metricID) { builder.add(metricID.getName(), myBuilder); } + private String exemplarForElapsedTime(Sample.Labeled sample) { + return sample == null ? "" : prometheusExemplar(sample.value(), sample); + } + private double elapsedTimeInSeconds() { return elapsedTimeInSeconds(getElapsedTime().toNanos()); } diff --git a/metrics/metrics/src/main/java/io/helidon/metrics/MetricsSupport.java b/metrics/metrics/src/main/java/io/helidon/metrics/MetricsSupport.java index 08306f9ca6d..a0ff6a47691 100644 --- a/metrics/metrics/src/main/java/io/helidon/metrics/MetricsSupport.java +++ b/metrics/metrics/src/main/java/io/helidon/metrics/MetricsSupport.java @@ -181,7 +181,10 @@ public static Builder builder() { } private static MediaType findBestAccepted(RequestHeaders headers) { - Optional mediaType = headers.bestAccepted(MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON); + Optional mediaType = headers.bestAccepted(MediaType.TEXT_PLAIN, + MediaType.APPLICATION_JSON, + MediaType.APPLICATION_OPENMETRICS); + return mediaType.orElse(null); } @@ -204,10 +207,10 @@ private static void getAll(ServerRequest req, ServerResponse res, Registry regis } MediaType mediaType = findBestAccepted(req.headers()); - if (mediaType == MediaType.APPLICATION_JSON) { + if (matches(mediaType, MediaType.APPLICATION_JSON)) { sendJson(res, toJsonData(registry)); - } else if (mediaType == MediaType.TEXT_PLAIN) { - res.send(toPrometheusData(registry)); + } else if (matches(mediaType, MediaType.TEXT_PLAIN, MediaType.APPLICATION_OPENMETRICS)) { + sendPrometheus(res, toPrometheusData(registry), mediaType); } else { res.status(Http.Status.NOT_ACCEPTABLE_406); res.send(); @@ -496,10 +499,10 @@ private void getByName(ServerRequest req, ServerResponse res, Registry registry) registry.getOptionalMetricEntry(metricName) .ifPresentOrElse(entry -> { MediaType mediaType = findBestAccepted(req.headers()); - if (mediaType == MediaType.APPLICATION_JSON) { + if (matches(mediaType, MediaType.APPLICATION_JSON)) { sendJson(res, jsonDataByName(registry, metricName)); - } else if (mediaType == MediaType.TEXT_PLAIN) { - res.send(prometheusDataByName(registry, metricName)); + } else if (matches(mediaType, MediaType.TEXT_PLAIN, MediaType.APPLICATION_OPENMETRICS)) { + sendPrometheus(res, prometheusDataByName(registry, metricName), mediaType); } else { res.status(Http.Status.NOT_ACCEPTABLE_406); res.send(); @@ -541,10 +544,10 @@ private static void sendJson(ServerResponse res, JsonObject object) { private void getMultiple(ServerRequest req, ServerResponse res, Registry... registries) { MediaType mediaType = findBestAccepted(req.headers()); res.cachingStrategy(ServerResponse.CachingStrategy.NO_CACHING); - if (mediaType == MediaType.APPLICATION_JSON) { + if (matches(mediaType, MediaType.APPLICATION_JSON)) { sendJson(res, toJsonData(registries)); - } else if (mediaType == MediaType.TEXT_PLAIN) { - res.send(toPrometheusData(registries)); + } else if (matches(mediaType, MediaType.TEXT_PLAIN, MediaType.APPLICATION_OPENMETRICS)) { + sendPrometheus(res, toPrometheusData(registries), mediaType); } else { res.status(Http.Status.NOT_ACCEPTABLE_406); res.send(); @@ -561,6 +564,15 @@ private void optionsMultiple(ServerRequest req, ServerResponse res, Registry... } } + private static boolean matches(MediaType candidateMediaType, MediaType... standardTypes) { + for (MediaType mt : standardTypes) { + if (mt.test(candidateMediaType)) { + return true; + } + } + return false; + } + private void optionsOne(ServerRequest req, ServerResponse res, Registry registry) { String metricName = req.path().param("metric"); @@ -581,6 +593,21 @@ private void optionsOne(ServerRequest req, ServerResponse res, Registry registry }); } + private static void sendPrometheus(ServerResponse res, String formattedOutput, MediaType requestedMediaType) { + MediaType.Builder responseMediaTypeBuilder = MediaType.builder() + .type(requestedMediaType.type()) + .subtype(requestedMediaType.subtype()) + .charset("UTF-8"); + + if (matches(requestedMediaType, MediaType.APPLICATION_OPENMETRICS)) { + responseMediaTypeBuilder.addParameter("version", "1.0.0"); + } else if (matches(requestedMediaType, MediaType.TEXT_PLAIN)) { + responseMediaTypeBuilder.addParameter("version", "0.0.4"); + } + res.addHeader("Content-Type", responseMediaTypeBuilder.build().toString()); + res.send(formattedOutput + "# EOF\n"); + } + /** * A fluent API builder to build instances of {@link MetricsSupport}. */ diff --git a/metrics/metrics/src/test/java/io/helidon/metrics/TestServer.java b/metrics/metrics/src/test/java/io/helidon/metrics/TestServer.java index 68df9e268fd..14dfbb5452f 100644 --- a/metrics/metrics/src/test/java/io/helidon/metrics/TestServer.java +++ b/metrics/metrics/src/test/java/io/helidon/metrics/TestServer.java @@ -18,6 +18,9 @@ import javax.json.JsonObject; +import java.io.IOException; +import java.io.LineNumberReader; +import java.io.StringReader; import java.util.Optional; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -38,6 +41,7 @@ import org.eclipse.microprofile.metrics.ConcurrentGauge; import org.eclipse.microprofile.metrics.MetricRegistry; import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -46,6 +50,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.endsWith; import static org.hamcrest.Matchers.greaterThan; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.not; @@ -62,6 +67,20 @@ public class TestServer { private static MetricsSupport metricsSupport; + private static final MediaType EXPECTED_OPENMETRICS_CONTENT_TYPE = MediaType.builder() + .type(MediaType.APPLICATION_OPENMETRICS.type()) + .subtype(MediaType.APPLICATION_OPENMETRICS.subtype()) + .addParameter("version", "1.0.0") + .charset("UTF-8") + .build(); + + private static final MediaType EXPECTED_PROMETHEUS_CONTENT_TYPE = MediaType.builder() + .type(MediaType.TEXT_PLAIN.type()) + .subtype(MediaType.TEXT_PLAIN.subtype()) + .addParameter("version", "0.0.4") + .charset("UTF-8") + .build(); + private WebClient.Builder webClientBuilder; @BeforeAll @@ -240,5 +259,44 @@ void testCacheSuppression(String pathSuffix) { response.headers().values(Http.Header.CACHE_CONTROL), not(containsInAnyOrder(EXPECTED_NO_CACHE_HEADER_SETTINGS))); } + @Test + void testOpenMetricsFormatting() throws IOException { + WebClientResponse response = webClientBuilder + .build() + .get() + .accept(MediaType.APPLICATION_OPENMETRICS) + .path("/metrics") + .submit() + .await(); + + assertThat("Content-Type", + response.headers().values(Http.Header.CONTENT_TYPE), + containsInAnyOrder(EXPECTED_OPENMETRICS_CONTENT_TYPE.toString())); + + String content = response.content().as(String.class).await(10, TimeUnit.SECONDS); + assertThat("Terminated content", content, endsWith("EOF\n")); + + LineNumberReader reader = new LineNumberReader(new StringReader(content)); + String line; + while ((line = reader.readLine()) != null) { + if (line.isBlank()) { + Assertions.fail("Found blank line where none is allowed in response: \n" + content); + } + } + } + @Test + void testPrometheusFormatting() { + WebClientResponse response = webClientBuilder + .build() + .get() + .accept(MediaType.TEXT_PLAIN) + .path("/metrics") + .submit() + .await(); + + assertThat("Content-Type", + response.headers().values(Http.Header.CONTENT_TYPE), + containsInAnyOrder(EXPECTED_PROMETHEUS_CONTENT_TYPE.toString())); + } }