Skip to content

Commit bc05352

Browse files
jkschneiderwilkinsona
authored andcommitted
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
1 parent e2453a1 commit bc05352

File tree

3 files changed

+171
-56
lines changed

3 files changed

+171
-56
lines changed

spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/metrics/MetricsEndpoint.java

Lines changed: 113 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,23 @@
1818

1919
import java.util.Collection;
2020
import java.util.Collections;
21+
import java.util.HashMap;
2122
import java.util.List;
2223
import java.util.Map;
2324
import java.util.stream.Collectors;
2425
import java.util.stream.Stream;
25-
import java.util.stream.StreamSupport;
2626

2727
import io.micrometer.core.instrument.Measurement;
2828
import io.micrometer.core.instrument.Meter;
2929
import io.micrometer.core.instrument.MeterRegistry;
30-
import io.micrometer.core.instrument.NamingConvention;
3130
import io.micrometer.core.instrument.Statistic;
32-
import io.micrometer.core.instrument.util.HierarchicalNameMapper;
31+
import io.micrometer.core.instrument.Tag;
3332

3433
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
3534
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
3635
import org.springframework.boot.actuate.endpoint.annotation.Selector;
36+
import org.springframework.lang.Nullable;
37+
import org.springframework.util.Assert;
3738

3839
/**
3940
* An {@link Endpoint} for exposing the metrics held by a {@link MeterRegistry}.
@@ -61,57 +62,133 @@ private String getMeterIdName(Meter meter) {
6162
}
6263

6364
@ReadOperation
64-
public Map<String, Collection<MeasurementSample>> metric(
65-
@Selector String requiredMetricName) {
66-
return this.registry.find(requiredMetricName).meters().stream()
67-
.collect(Collectors.toMap(this::getHierarchicalName, this::getSamples));
68-
}
69-
70-
private List<MeasurementSample> getSamples(Meter meter) {
71-
return stream(meter.measure()).map(this::getSample).collect(Collectors.toList());
72-
}
65+
public Response metric(@Selector String requiredMetricName,
66+
@Nullable List<String> tag) {
67+
Assert.isTrue(tag == null || tag.stream().allMatch((t) -> t.contains(":")),
68+
"Each tag parameter must be in the form key:value");
69+
List<Tag> tags = parseTags(tag);
70+
Collection<Meter> meters = this.registry.find(requiredMetricName).tags(tags)
71+
.meters();
72+
if (meters.isEmpty()) {
73+
return null;
74+
}
7375

74-
private MeasurementSample getSample(Measurement measurement) {
75-
return new MeasurementSample(measurement.getStatistic(), measurement.getValue());
76-
}
76+
Map<Statistic, Double> samples = new HashMap<>();
77+
Map<String, List<String>> availableTags = new HashMap<>();
78+
79+
for (Meter meter : meters) {
80+
for (Measurement ms : meter.measure()) {
81+
samples.merge(ms.getStatistic(), ms.getValue(), Double::sum);
82+
}
83+
for (Tag availableTag : meter.getId().getTags()) {
84+
availableTags.merge(availableTag.getKey(),
85+
Collections.singletonList(availableTag.getValue()),
86+
(t1, t2) -> Stream.concat(t1.stream(), t2.stream())
87+
.collect(Collectors.toList()));
88+
}
89+
}
7790

78-
private String getHierarchicalName(Meter meter) {
79-
return HierarchicalNameMapper.DEFAULT.toHierarchicalName(meter.getId(),
80-
NamingConvention.camelCase);
91+
tags.forEach((t) -> availableTags.remove(t.getKey()));
92+
93+
return new Response(requiredMetricName,
94+
samples.entrySet().stream()
95+
.map((sample) -> new Response.Sample(sample.getKey(),
96+
sample.getValue()))
97+
.collect(
98+
Collectors.toList()),
99+
availableTags.entrySet().stream()
100+
.map((tagValues) -> new Response.AvailableTag(tagValues.getKey(),
101+
tagValues.getValue()))
102+
.collect(Collectors.toList()));
81103
}
82104

83-
private <T> Stream<T> stream(Iterable<T> measure) {
84-
return StreamSupport.stream(measure.spliterator(), false);
105+
private List<Tag> parseTags(List<String> tags) {
106+
return tags == null ? Collections.emptyList() : tags.stream().map((t) -> {
107+
String[] tagParts = t.split(":", 2);
108+
return Tag.of(tagParts[0], tagParts[1]);
109+
}).collect(Collectors.toList());
85110
}
86111

87112
/**
88-
* A measurement sample combining a {@link Statistic statistic} and a value.
113+
* Response payload.
89114
*/
90-
static class MeasurementSample {
115+
static class Response {
91116

92-
private final Statistic statistic;
117+
private final String name;
93118

94-
private final Double value;
119+
private final List<Sample> measurements;
95120

96-
MeasurementSample(Statistic statistic, Double value) {
97-
this.statistic = statistic;
98-
this.value = value;
121+
private final List<AvailableTag> availableTags;
122+
123+
Response(String name, List<Sample> measurements,
124+
List<AvailableTag> availableTags) {
125+
this.name = name;
126+
this.measurements = measurements;
127+
this.availableTags = availableTags;
99128
}
100129

101-
public Statistic getStatistic() {
102-
return this.statistic;
130+
public String getName() {
131+
return this.name;
103132
}
104133

105-
public Double getValue() {
106-
return this.value;
134+
public List<Sample> getMeasurements() {
135+
return this.measurements;
107136
}
108137

109-
@Override
110-
public String toString() {
111-
return "MeasurementSample{" + "statistic=" + this.statistic + ", value="
112-
+ this.value + '}';
138+
public List<AvailableTag> getAvailableTags() {
139+
return this.availableTags;
113140
}
114141

115-
}
142+
/**
143+
* A set of tags for further dimensional drilldown and their potential values.
144+
*/
145+
static class AvailableTag {
146+
147+
private final String tag;
148+
149+
private final List<String> values;
150+
151+
AvailableTag(String tag, List<String> values) {
152+
this.tag = tag;
153+
this.values = values;
154+
}
116155

156+
public String getTag() {
157+
return this.tag;
158+
}
159+
160+
public List<String> getValues() {
161+
return this.values;
162+
}
163+
}
164+
165+
/**
166+
* A measurement sample combining a {@link Statistic statistic} and a value.
167+
*/
168+
static class Sample {
169+
170+
private final Statistic statistic;
171+
172+
private final Double value;
173+
174+
Sample(Statistic statistic, Double value) {
175+
this.statistic = statistic;
176+
this.value = value;
177+
}
178+
179+
public Statistic getStatistic() {
180+
return this.statistic;
181+
}
182+
183+
public Double getValue() {
184+
return this.value;
185+
}
186+
187+
@Override
188+
public String toString() {
189+
return "MeasurementSample{" + "statistic=" + this.statistic + ", value="
190+
+ this.value + '}';
191+
}
192+
}
193+
}
117194
}

spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/MetricsEndpointTests.java

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,58 +16,90 @@
1616

1717
package org.springframework.boot.actuate.metrics;
1818

19-
import java.util.Arrays;
19+
import java.util.Collections;
2020
import java.util.List;
2121
import java.util.Map;
22+
import java.util.Optional;
23+
import java.util.stream.Stream;
2224

23-
import io.micrometer.core.instrument.Meter;
24-
import io.micrometer.core.instrument.Meter.Id;
2525
import io.micrometer.core.instrument.MeterRegistry;
26-
import io.micrometer.core.instrument.simple.SimpleCounter;
26+
import io.micrometer.core.instrument.Statistic;
27+
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
2728
import org.junit.Test;
2829

2930
import static org.assertj.core.api.Assertions.assertThat;
30-
import static org.mockito.BDDMockito.given;
31-
import static org.mockito.Mockito.mock;
3231

3332
/**
3433
* Tests for {@link MetricsEndpoint}.
3534
*
3635
* @author Andy Wilkinson
36+
* @author Jon Schneider
3737
*/
3838
public class MetricsEndpointTests {
3939

40-
private final MeterRegistry registry = mock(MeterRegistry.class);
40+
private final MeterRegistry registry = new SimpleMeterRegistry();
4141

4242
private final MetricsEndpoint endpoint = new MetricsEndpoint(this.registry);
4343

4444
@Test
4545
public void listNamesHandlesEmptyListOfMeters() {
46-
given(this.registry.getMeters()).willReturn(Arrays.asList());
4746
Map<String, List<String>> result = this.endpoint.listNames();
4847
assertThat(result).containsOnlyKeys("names");
4948
assertThat(result.get("names")).isEmpty();
5049
}
5150

5251
@Test
5352
public void listNamesProducesListOfUniqueMeterNames() {
54-
List<Meter> meters = Arrays.asList(createCounter("com.example.foo"),
55-
createCounter("com.example.bar"), createCounter("com.example.foo"));
56-
given(this.registry.getMeters()).willReturn(meters);
53+
this.registry.counter("com.example.foo");
54+
this.registry.counter("com.example.bar");
55+
this.registry.counter("com.example.foo");
5756
Map<String, List<String>> result = this.endpoint.listNames();
5857
assertThat(result).containsOnlyKeys("names");
5958
assertThat(result.get("names")).containsOnlyOnce("com.example.foo",
6059
"com.example.bar");
6160
}
6261

63-
private Meter createCounter(String name) {
64-
return new SimpleCounter(createMeterId(name));
62+
@Test
63+
public void metricValuesAreTheSumOfAllTimeSeriesMatchingTags() {
64+
this.registry.counter("cache", "result", "hit", "host", "1").increment(2);
65+
this.registry.counter("cache", "result", "miss", "host", "1").increment(2);
66+
this.registry.counter("cache", "result", "hit", "host", "2").increment(2);
67+
MetricsEndpoint.Response response = this.endpoint.metric("cache",
68+
Collections.emptyList());
69+
assertThat(response.getName()).isEqualTo("cache");
70+
assertThat(availableTagKeys(response)).containsExactly("result", "host");
71+
assertThat(getCount(response)).hasValue(6.0);
72+
response = this.endpoint.metric("cache", Collections.singletonList("result:hit"));
73+
assertThat(availableTagKeys(response)).containsExactly("host");
74+
assertThat(getCount(response)).hasValue(4.0);
75+
}
76+
77+
@Test
78+
public void metricWithSpaceInTagValue() {
79+
this.registry.counter("counter", "key", "a space").increment(2);
80+
MetricsEndpoint.Response response = this.endpoint.metric("counter",
81+
Collections.singletonList("key:a space"));
82+
assertThat(response.getName()).isEqualTo("counter");
83+
assertThat(availableTagKeys(response)).isEmpty();
84+
assertThat(getCount(response)).hasValue(2.0);
85+
}
86+
87+
@Test
88+
public void nonExistentMetric() {
89+
MetricsEndpoint.Response response = this.endpoint.metric("does.not.exist",
90+
Collections.emptyList());
91+
assertThat(response).isNull();
92+
}
93+
94+
private Optional<Double> getCount(MetricsEndpoint.Response response) {
95+
return response.getMeasurements().stream()
96+
.filter((ms) -> ms.getStatistic().equals(Statistic.Count)).findAny()
97+
.map(MetricsEndpoint.Response.Sample::getValue);
6598
}
6699

67-
private Id createMeterId(String name) {
68-
Id id = mock(Id.class);
69-
given(id.getName()).willReturn(name);
70-
return id;
100+
private Stream<String> availableTagKeys(MetricsEndpoint.Response response) {
101+
return response.getAvailableTags().stream()
102+
.map(MetricsEndpoint.Response.AvailableTag::getTag);
71103
}
72104

73105
}

spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/metrics/MetricsEndpointWebIntegrationTests.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,15 @@ public void listNames() throws IOException {
6161
public void selectByName() throws IOException {
6262
MetricsEndpointWebIntegrationTests.client.get()
6363
.uri("/application/metrics/jvm.memory.used").exchange().expectStatus()
64-
.isOk().expectBody()
65-
.jsonPath("['jvmMemoryUsed.area.nonheap.id.Compressed_Class_Space']")
66-
.exists().jsonPath("['jvmMemoryUsed.area.heap.id.PS_Old_Gen']");
64+
.isOk().expectBody().jsonPath("$.name").isEqualTo("jvm.memory.used");
65+
}
66+
67+
@Test
68+
public void selectByTag() {
69+
MetricsEndpointWebIntegrationTests.client.get()
70+
.uri("/application/metrics/jvm.memory.used?tag=id:PS%20Old%20Gen")
71+
.exchange().expectStatus().isOk().expectBody().jsonPath("$.name")
72+
.isEqualTo("jvm.memory.used");
6773
}
6874

6975
@Configuration

0 commit comments

Comments
 (0)