diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/MetricsEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/MetricsEndpoint.java index 7f61ccd272..f41c6cb50b 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/MetricsEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/MetricsEndpoint.java @@ -18,22 +18,23 @@ package org.springframework.boot.actuate.metrics; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; -import java.util.stream.StreamSupport; import io.micrometer.core.instrument.Measurement; import io.micrometer.core.instrument.Meter; import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.NamingConvention; import io.micrometer.core.instrument.Statistic; -import io.micrometer.core.instrument.util.HierarchicalNameMapper; +import io.micrometer.core.instrument.Tag; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.actuate.endpoint.annotation.Selector; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; /** * An {@link Endpoint} for exposing the metrics held by a {@link MeterRegistry}. @@ -61,57 +62,133 @@ public class MetricsEndpoint { } @ReadOperation - public Map> metric( - @Selector String requiredMetricName) { - return this.registry.find(requiredMetricName).meters().stream() - .collect(Collectors.toMap(this::getHierarchicalName, this::getSamples)); + public Response metric(@Selector String requiredMetricName, + @Nullable List tag) { + Assert.isTrue(tag == null || tag.stream().allMatch((t) -> t.contains(":")), + "Each tag parameter must be in the form key:value"); + List tags = parseTags(tag); + Collection meters = this.registry.find(requiredMetricName).tags(tags) + .meters(); + if (meters.isEmpty()) { + return null; + } + + Map samples = new HashMap<>(); + Map> availableTags = new HashMap<>(); + + for (Meter meter : meters) { + for (Measurement ms : meter.measure()) { + samples.merge(ms.getStatistic(), ms.getValue(), Double::sum); + } + for (Tag availableTag : meter.getId().getTags()) { + availableTags.merge(availableTag.getKey(), + Collections.singletonList(availableTag.getValue()), + (t1, t2) -> Stream.concat(t1.stream(), t2.stream()) + .collect(Collectors.toList())); + } + } + + tags.forEach((t) -> availableTags.remove(t.getKey())); + + return new Response(requiredMetricName, + samples.entrySet().stream() + .map((sample) -> new Response.Sample(sample.getKey(), + sample.getValue())) + .collect( + Collectors.toList()), + availableTags.entrySet().stream() + .map((tagValues) -> new Response.AvailableTag(tagValues.getKey(), + tagValues.getValue())) + .collect(Collectors.toList())); } - private List getSamples(Meter meter) { - return stream(meter.measure()).map(this::getSample).collect(Collectors.toList()); - } - - private MeasurementSample getSample(Measurement measurement) { - return new MeasurementSample(measurement.getStatistic(), measurement.getValue()); - } - - private String getHierarchicalName(Meter meter) { - return HierarchicalNameMapper.DEFAULT.toHierarchicalName(meter.getId(), - NamingConvention.camelCase); - } - - private Stream stream(Iterable measure) { - return StreamSupport.stream(measure.spliterator(), false); + private List parseTags(List tags) { + return tags == null ? Collections.emptyList() : tags.stream().map((t) -> { + String[] tagParts = t.split(":", 2); + return Tag.of(tagParts[0], tagParts[1]); + }).collect(Collectors.toList()); } /** - * A measurement sample combining a {@link Statistic statistic} and a value. + * Response payload. */ - static class MeasurementSample { + static class Response { - private final Statistic statistic; + private final String name; - private final Double value; + private final List measurements; - MeasurementSample(Statistic statistic, Double value) { - this.statistic = statistic; - this.value = value; + private final List availableTags; + + Response(String name, List measurements, + List availableTags) { + this.name = name; + this.measurements = measurements; + this.availableTags = availableTags; } - public Statistic getStatistic() { - return this.statistic; + public String getName() { + return this.name; } - public Double getValue() { - return this.value; + public List getMeasurements() { + return this.measurements; } - @Override - public String toString() { - return "MeasurementSample{" + "statistic=" + this.statistic + ", value=" - + this.value + '}'; + public List getAvailableTags() { + return this.availableTags; } + /** + * A set of tags for further dimensional drilldown and their potential values. + */ + static class AvailableTag { + + private final String tag; + + private final List values; + + AvailableTag(String tag, List values) { + this.tag = tag; + this.values = values; + } + + public String getTag() { + return this.tag; + } + + public List getValues() { + return this.values; + } + } + + /** + * A measurement sample combining a {@link Statistic statistic} and a value. + */ + static class Sample { + + private final Statistic statistic; + + private final Double value; + + Sample(Statistic statistic, Double value) { + this.statistic = statistic; + this.value = value; + } + + public Statistic getStatistic() { + return this.statistic; + } + + public Double getValue() { + return this.value; + } + + @Override + public String toString() { + return "MeasurementSample{" + "statistic=" + this.statistic + ", value=" + + this.value + '}'; + } + } } - } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/MetricsEndpointTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/MetricsEndpointTests.java index d3cf8b95fc..f3e85f7833 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/MetricsEndpointTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/MetricsEndpointTests.java @@ -16,34 +16,33 @@ package org.springframework.boot.actuate.metrics; -import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; -import io.micrometer.core.instrument.Meter; -import io.micrometer.core.instrument.Meter.Id; import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.simple.SimpleCounter; +import io.micrometer.core.instrument.Statistic; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.Test; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; /** * Tests for {@link MetricsEndpoint}. * * @author Andy Wilkinson + * @author Jon Schneider */ public class MetricsEndpointTests { - private final MeterRegistry registry = mock(MeterRegistry.class); + private final MeterRegistry registry = new SimpleMeterRegistry(); private final MetricsEndpoint endpoint = new MetricsEndpoint(this.registry); @Test public void listNamesHandlesEmptyListOfMeters() { - given(this.registry.getMeters()).willReturn(Arrays.asList()); Map> result = this.endpoint.listNames(); assertThat(result).containsOnlyKeys("names"); assertThat(result.get("names")).isEmpty(); @@ -51,23 +50,56 @@ public class MetricsEndpointTests { @Test public void listNamesProducesListOfUniqueMeterNames() { - List meters = Arrays.asList(createCounter("com.example.foo"), - createCounter("com.example.bar"), createCounter("com.example.foo")); - given(this.registry.getMeters()).willReturn(meters); + this.registry.counter("com.example.foo"); + this.registry.counter("com.example.bar"); + this.registry.counter("com.example.foo"); Map> result = this.endpoint.listNames(); assertThat(result).containsOnlyKeys("names"); assertThat(result.get("names")).containsOnlyOnce("com.example.foo", "com.example.bar"); } - private Meter createCounter(String name) { - return new SimpleCounter(createMeterId(name)); + @Test + public void metricValuesAreTheSumOfAllTimeSeriesMatchingTags() { + this.registry.counter("cache", "result", "hit", "host", "1").increment(2); + this.registry.counter("cache", "result", "miss", "host", "1").increment(2); + this.registry.counter("cache", "result", "hit", "host", "2").increment(2); + MetricsEndpoint.Response response = this.endpoint.metric("cache", + Collections.emptyList()); + assertThat(response.getName()).isEqualTo("cache"); + assertThat(availableTagKeys(response)).containsExactly("result", "host"); + assertThat(getCount(response)).hasValue(6.0); + response = this.endpoint.metric("cache", Collections.singletonList("result:hit")); + assertThat(availableTagKeys(response)).containsExactly("host"); + assertThat(getCount(response)).hasValue(4.0); } - private Id createMeterId(String name) { - Id id = mock(Id.class); - given(id.getName()).willReturn(name); - return id; + @Test + public void metricWithSpaceInTagValue() { + this.registry.counter("counter", "key", "a space").increment(2); + MetricsEndpoint.Response response = this.endpoint.metric("counter", + Collections.singletonList("key:a space")); + assertThat(response.getName()).isEqualTo("counter"); + assertThat(availableTagKeys(response)).isEmpty(); + assertThat(getCount(response)).hasValue(2.0); + } + + @Test + public void nonExistentMetric() { + MetricsEndpoint.Response response = this.endpoint.metric("does.not.exist", + Collections.emptyList()); + assertThat(response).isNull(); + } + + private Optional getCount(MetricsEndpoint.Response response) { + return response.getMeasurements().stream() + .filter((ms) -> ms.getStatistic().equals(Statistic.Count)).findAny() + .map(MetricsEndpoint.Response.Sample::getValue); + } + + private Stream availableTagKeys(MetricsEndpoint.Response response) { + return response.getAvailableTags().stream() + .map(MetricsEndpoint.Response.AvailableTag::getTag); } } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/MetricsEndpointWebIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/MetricsEndpointWebIntegrationTests.java index 1893c11a61..ed4b1d6f4e 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/MetricsEndpointWebIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/MetricsEndpointWebIntegrationTests.java @@ -61,9 +61,15 @@ public class MetricsEndpointWebIntegrationTests { public void selectByName() throws IOException { MetricsEndpointWebIntegrationTests.client.get() .uri("/application/metrics/jvm.memory.used").exchange().expectStatus() - .isOk().expectBody() - .jsonPath("['jvmMemoryUsed.area.nonheap.id.Compressed_Class_Space']") - .exists().jsonPath("['jvmMemoryUsed.area.heap.id.PS_Old_Gen']"); + .isOk().expectBody().jsonPath("$.name").isEqualTo("jvm.memory.used"); + } + + @Test + public void selectByTag() { + MetricsEndpointWebIntegrationTests.client.get() + .uri("/application/metrics/jvm.memory.used?tag=id:PS%20Old%20Gen") + .exchange().expectStatus().isOk().expectBody().jsonPath("$.name") + .isEqualTo("jvm.memory.used"); } @Configuration