Commit bc053522 authored by Jon Schneider's avatar Jon Schneider Committed by Andy Wilkinson

Improve new metrics endpoint

- New repeatable tag query parameter to refine a query by one or more
  tag key/value pairs.
- Selecting a metric by name (and optionally a set of tags) reports
  statistics that are the sum of the statistics on all time series
  containing the name (and tags).

Closes gh-10524
parent e2453a17
...@@ -18,22 +18,23 @@ package org.springframework.boot.actuate.metrics; ...@@ -18,22 +18,23 @@ package org.springframework.boot.actuate.metrics;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import io.micrometer.core.instrument.Measurement; import io.micrometer.core.instrument.Measurement;
import io.micrometer.core.instrument.Meter; import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.NamingConvention;
import io.micrometer.core.instrument.Statistic; 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.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector; 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}. * An {@link Endpoint} for exposing the metrics held by a {@link MeterRegistry}.
...@@ -61,57 +62,133 @@ public class MetricsEndpoint { ...@@ -61,57 +62,133 @@ public class MetricsEndpoint {
} }
@ReadOperation @ReadOperation
public Map<String, Collection<MeasurementSample>> metric( public Response metric(@Selector String requiredMetricName,
@Selector String requiredMetricName) { @Nullable List<String> tag) {
return this.registry.find(requiredMetricName).meters().stream() Assert.isTrue(tag == null || tag.stream().allMatch((t) -> t.contains(":")),
.collect(Collectors.toMap(this::getHierarchicalName, this::getSamples)); "Each tag parameter must be in the form key:value");
} List<Tag> tags = parseTags(tag);
Collection<Meter> meters = this.registry.find(requiredMetricName).tags(tags)
private List<MeasurementSample> getSamples(Meter meter) { .meters();
return stream(meter.measure()).map(this::getSample).collect(Collectors.toList()); if (meters.isEmpty()) {
} return null;
}
private MeasurementSample getSample(Measurement measurement) { Map<Statistic, Double> samples = new HashMap<>();
return new MeasurementSample(measurement.getStatistic(), measurement.getValue()); Map<String, List<String>> 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()));
}
}
private String getHierarchicalName(Meter meter) { tags.forEach((t) -> availableTags.remove(t.getKey()));
return HierarchicalNameMapper.DEFAULT.toHierarchicalName(meter.getId(),
NamingConvention.camelCase); 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 <T> Stream<T> stream(Iterable<T> measure) { private List<Tag> parseTags(List<String> tags) {
return StreamSupport.stream(measure.spliterator(), false); 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<Sample> measurements;
MeasurementSample(Statistic statistic, Double value) { private final List<AvailableTag> availableTags;
this.statistic = statistic;
this.value = value; Response(String name, List<Sample> measurements,
List<AvailableTag> availableTags) {
this.name = name;
this.measurements = measurements;
this.availableTags = availableTags;
} }
public Statistic getStatistic() { public String getName() {
return this.statistic; return this.name;
} }
public Double getValue() { public List<Sample> getMeasurements() {
return this.value; return this.measurements;
} }
@Override public List<AvailableTag> getAvailableTags() {
public String toString() { return this.availableTags;
return "MeasurementSample{" + "statistic=" + this.statistic + ", value="
+ this.value + '}';
} }
} /**
* A set of tags for further dimensional drilldown and their potential values.
*/
static class AvailableTag {
private final String tag;
private final List<String> values;
AvailableTag(String tag, List<String> values) {
this.tag = tag;
this.values = values;
}
public String getTag() {
return this.tag;
}
public List<String> 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 + '}';
}
}
}
} }
...@@ -16,34 +16,33 @@ ...@@ -16,34 +16,33 @@
package org.springframework.boot.actuate.metrics; package org.springframework.boot.actuate.metrics;
import java.util.Arrays; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; 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.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 org.junit.Test;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/** /**
* Tests for {@link MetricsEndpoint}. * Tests for {@link MetricsEndpoint}.
* *
* @author Andy Wilkinson * @author Andy Wilkinson
* @author Jon Schneider
*/ */
public class MetricsEndpointTests { public class MetricsEndpointTests {
private final MeterRegistry registry = mock(MeterRegistry.class); private final MeterRegistry registry = new SimpleMeterRegistry();
private final MetricsEndpoint endpoint = new MetricsEndpoint(this.registry); private final MetricsEndpoint endpoint = new MetricsEndpoint(this.registry);
@Test @Test
public void listNamesHandlesEmptyListOfMeters() { public void listNamesHandlesEmptyListOfMeters() {
given(this.registry.getMeters()).willReturn(Arrays.asList());
Map<String, List<String>> result = this.endpoint.listNames(); Map<String, List<String>> result = this.endpoint.listNames();
assertThat(result).containsOnlyKeys("names"); assertThat(result).containsOnlyKeys("names");
assertThat(result.get("names")).isEmpty(); assertThat(result.get("names")).isEmpty();
...@@ -51,23 +50,56 @@ public class MetricsEndpointTests { ...@@ -51,23 +50,56 @@ public class MetricsEndpointTests {
@Test @Test
public void listNamesProducesListOfUniqueMeterNames() { public void listNamesProducesListOfUniqueMeterNames() {
List<Meter> meters = Arrays.asList(createCounter("com.example.foo"), this.registry.counter("com.example.foo");
createCounter("com.example.bar"), createCounter("com.example.foo")); this.registry.counter("com.example.bar");
given(this.registry.getMeters()).willReturn(meters); this.registry.counter("com.example.foo");
Map<String, List<String>> result = this.endpoint.listNames(); Map<String, List<String>> result = this.endpoint.listNames();
assertThat(result).containsOnlyKeys("names"); assertThat(result).containsOnlyKeys("names");
assertThat(result.get("names")).containsOnlyOnce("com.example.foo", assertThat(result.get("names")).containsOnlyOnce("com.example.foo",
"com.example.bar"); "com.example.bar");
} }
private Meter createCounter(String name) { @Test
return new SimpleCounter(createMeterId(name)); 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);
}
@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<Double> getCount(MetricsEndpoint.Response response) {
return response.getMeasurements().stream()
.filter((ms) -> ms.getStatistic().equals(Statistic.Count)).findAny()
.map(MetricsEndpoint.Response.Sample::getValue);
} }
private Id createMeterId(String name) { private Stream<String> availableTagKeys(MetricsEndpoint.Response response) {
Id id = mock(Id.class); return response.getAvailableTags().stream()
given(id.getName()).willReturn(name); .map(MetricsEndpoint.Response.AvailableTag::getTag);
return id;
} }
} }
...@@ -61,9 +61,15 @@ public class MetricsEndpointWebIntegrationTests { ...@@ -61,9 +61,15 @@ public class MetricsEndpointWebIntegrationTests {
public void selectByName() throws IOException { public void selectByName() throws IOException {
MetricsEndpointWebIntegrationTests.client.get() MetricsEndpointWebIntegrationTests.client.get()
.uri("/application/metrics/jvm.memory.used").exchange().expectStatus() .uri("/application/metrics/jvm.memory.used").exchange().expectStatus()
.isOk().expectBody() .isOk().expectBody().jsonPath("$.name").isEqualTo("jvm.memory.used");
.jsonPath("['jvmMemoryUsed.area.nonheap.id.Compressed_Class_Space']") }
.exists().jsonPath("['jvmMemoryUsed.area.heap.id.PS_Old_Gen']");
@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 @Configuration
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment