Skip to content

Commit 1aa68ff

Browse files
authored
Custom actuator metric with request counter (#836)
* bump version to 1.20.0 (metrics are a new feature) * expose actuator metrics endpoint, but only enable http.server.requests metrics this only works after the configuration changes from #906 * add ActuatorConfig to customize the http.server.requests metric (so it shows all individual resource endpoints instead of grouping them by pattern) Here we override the DefaultServerRequestObservationConvention instead of subclassing the ObservationFilter Based on advise by micrometer devs. * add TestMetrics class with metrics filter check * test values of http.url tag * test that endpoint visits are counted * require authorization for actuator metrics endpoints * test unauthenticated access to actuator metrics * accept header not necessary for metrics auth test * test auth for metric uri instead of metric list uri, so we can be sure the authentication requirement also applies to sub-paths of /actuator/metrics
1 parent 155fa28 commit 1aa68ff

6 files changed

Lines changed: 215 additions & 8 deletions

File tree

SECURITY.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ For older versions, we highly recommend upgrading to the latest version.
77

88
| Version | Supported |
99
|---------| ------------------ |
10-
| 1.19.x | :white_check_mark: |
11-
| < 1.19 | :x: |
10+
| 1.20.x | :white_check_mark: |
11+
| < 1.20 | :x: |
1212

1313
## Current Recommendations
1414

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
</parent>
1111
<groupId>org.fairdatateam</groupId>
1212
<artifactId>fairdatapoint</artifactId>
13-
<version>1.19.1</version>
13+
<version>1.20.0</version>
1414
<packaging>jar</packaging>
1515

1616
<name>FairDataPoint</name>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* The MIT License
3+
* Copyright © 2017 FAIR Data Team
4+
*
5+
* Permission is hereby granted, free of charge, to any person obtaining a copy
6+
* of this software and associated documentation files (the "Software"), to deal
7+
* in the Software without restriction, including without limitation the rights
8+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
* copies of the Software, and to permit persons to whom the Software is
10+
* furnished to do so, subject to the following conditions:
11+
*
12+
* The above copyright notice and this permission notice shall be included in
13+
* all copies or substantial portions of the Software.
14+
*
15+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
* THE SOFTWARE.
22+
*/
23+
package org.fairdatateam.fairdatapoint.config;
24+
25+
import io.micrometer.common.KeyValue;
26+
import org.springframework.context.annotation.Bean;
27+
import org.springframework.context.annotation.Configuration;
28+
import org.springframework.http.server.observation.DefaultServerRequestObservationConvention;
29+
import org.springframework.http.server.observation.ServerRequestObservationContext;
30+
31+
@Configuration
32+
public class ActuatorConfig {
33+
34+
/**
35+
* Replaces the default `uri` path-pattern values with full paths in actuator metrics http.server.requests
36+
*/
37+
@Bean
38+
DefaultServerRequestObservationConvention customServerRequestObservationConvention() {
39+
return new DefaultServerRequestObservationConvention() {
40+
/**
41+
* Replace the default URI path pattern (e.g. `/catalog/{id}`) with the full URI path (e.g. `/catalog/123`).
42+
* This is high-cardinality data, but we add it as low-cardinality to make sure it shows up in the metrics.
43+
* Beware, this could lead to memory issues in case of excessive number of URIs.
44+
* See micrometer docs for more info.
45+
* @param context ServerRequestObservationContext
46+
* @return KeyValue with full uri
47+
*/
48+
@Override
49+
protected KeyValue uri(ServerRequestObservationContext context) {
50+
// note the resulting tag name is "http.url" instead of "uri"
51+
return super.httpUrl(context);
52+
}
53+
};
54+
}
55+
}

src/main/java/org/fairdatateam/fairdatapoint/config/SecurityConfig.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
package org.fairdatateam.fairdatapoint.config;
2424

2525
import org.fairdatateam.fairdatapoint.api.filter.FilterConfigurer;
26+
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
2627
import org.springframework.context.annotation.Bean;
2728
import org.springframework.context.annotation.Configuration;
2829
import org.springframework.http.HttpMethod;
@@ -62,6 +63,7 @@ public SecurityFilterChain filterChain(HttpSecurity http, FilterConfigurer filte
6263
.requestMatchers("/index/admin**").authenticated()
6364
.requestMatchers("/index**").permitAll()
6465
.requestMatchers(HttpMethod.PUT).authenticated()
66+
.requestMatchers(EndpointRequest.to("metrics")).authenticated()
6567
.anyRequest().permitAll();
6668
}
6769
)

src/main/resources/application.yml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,18 @@ spring:
2626
thread-name-prefix: fdp-task-
2727

2828
management:
29-
health:
30-
solr:
31-
enabled: false
3229
info:
3330
defaults:
3431
enabled: false
3532
endpoints:
3633
web:
3734
exposure:
38-
include: health, info
35+
include: health, info, metrics
36+
metrics:
37+
enable:
38+
all: false
39+
http.server.requests: true
40+
http.server.requests.active: false
3941

4042
security:
4143
jwt:
@@ -67,7 +69,7 @@ metadataProperties:
6769

6870
openapi:
6971
title: FAIR Data Point API
70-
version: 1.19.1
72+
version: 1.20.0
7173
description: "The reference implementation of the metadata registration service: A service implementing the API specification. It contains an authentication system to allow maintainers to define and update metadata. Read-only access to the data is public."
7274
contact:
7375
name: Luiz Bonino
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* The MIT License
3+
* Copyright © 2017 FAIR Data Team
4+
*
5+
* Permission is hereby granted, free of charge, to any person obtaining a copy
6+
* of this software and associated documentation files (the "Software"), to deal
7+
* in the Software without restriction, including without limitation the rights
8+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
* copies of the Software, and to permit persons to whom the Software is
10+
* furnished to do so, subject to the following conditions:
11+
*
12+
* The above copyright notice and this permission notice shall be included in
13+
* all copies or substantial portions of the Software.
14+
*
15+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21+
* THE SOFTWARE.
22+
*/
23+
package org.fairdatateam.fairdatapoint.acceptance.actuator;
24+
25+
import io.micrometer.core.instrument.MeterRegistry;
26+
import org.fairdatateam.fairdatapoint.Profiles;
27+
import org.junit.jupiter.api.BeforeEach;
28+
import org.junit.jupiter.api.Test;
29+
import org.springframework.beans.factory.annotation.Autowired;
30+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
31+
import org.springframework.boot.test.context.SpringBootTest;
32+
import org.springframework.http.HttpStatus;
33+
import org.springframework.http.MediaType;
34+
import org.springframework.security.test.context.support.WithAnonymousUser;
35+
import org.springframework.security.test.context.support.WithMockUser;
36+
import org.springframework.test.context.ActiveProfiles;
37+
import org.springframework.test.web.servlet.assertj.MockMvcTester;
38+
import org.springframework.test.web.servlet.assertj.MvcTestResult;
39+
import org.springframework.web.util.UriComponentsBuilder;
40+
41+
import java.net.URI;
42+
43+
import static org.assertj.core.api.Assertions.assertThat;
44+
45+
46+
@ActiveProfiles(Profiles.TESTING)
47+
@AutoConfigureMockMvc
48+
@SpringBootTest
49+
@WithMockUser
50+
class MetricsTest {
51+
52+
final String metricName = "http.server.requests";
53+
final UriComponentsBuilder metricsUriBuilder = UriComponentsBuilder.fromPath("/actuator/metrics");
54+
final String tagName = "http.url";
55+
final String uriVisited = "/meta";
56+
57+
private final MeterRegistry meterRegistry;
58+
59+
// https://docs.spring.io/spring-framework/reference/testing/mockmvc/assertj.html
60+
private final MockMvcTester mockMvc;
61+
62+
/**
63+
* Constructor
64+
*/
65+
@Autowired
66+
MetricsTest(MeterRegistry meterRegistry, MockMvcTester mockMvc) {
67+
this.meterRegistry = meterRegistry;
68+
this.mockMvc = mockMvc;
69+
}
70+
71+
@BeforeEach
72+
void clearMeterRegistry() {
73+
// clear all metrics
74+
meterRegistry.clear();
75+
}
76+
77+
@Test
78+
@WithAnonymousUser
79+
void onlyAuthenticatedUsersCanAccessMetrics() {
80+
MvcTestResult testResult = mockMvc.get()
81+
.uri(metricsUriBuilder.pathSegment(metricName).build().toUri())
82+
.exchange();
83+
// todo: should actually be 401 UNAUTHORIZED (see #704)
84+
assertThat(testResult).hasStatus(HttpStatus.FORBIDDEN);
85+
}
86+
87+
@Test
88+
void onlyExposesHttpServerRequestsMetric() {
89+
// visit some endpoint to initialize the "http.server.requests" metric
90+
mockMvc.get().uri(uriVisited).exchange();
91+
// get metrics list
92+
MvcTestResult testResult = mockMvc.get()
93+
.uri(metricsUriBuilder.build().toUri())
94+
.accept(MediaType.APPLICATION_JSON)
95+
.exchange();
96+
// results should only include the "http.server.requests" (this only appears if endpoints have been visited)
97+
assertThat(testResult)
98+
.hasStatusOk()
99+
.hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
100+
.bodyJson()
101+
.extractingPath("$.names")
102+
.asArray()
103+
.containsExactly(metricName);
104+
}
105+
106+
@Test
107+
void listsVisitedEndpointUris() {
108+
// visit some endpoint to initialize the "http.server.requests" metric
109+
mockMvc.get().uri(uriVisited).exchange();
110+
// get the available metrics for "http.server.requests"
111+
MvcTestResult testResult = mockMvc.get()
112+
.uri(metricsUriBuilder.pathSegment(metricName).build().toUri())
113+
.accept(MediaType.APPLICATION_JSON)
114+
.exchange();
115+
// the visited endpoint should be included in the available tag values for "http.url"
116+
assertThat(testResult).hasStatusOk().hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON);
117+
assertThat(testResult).bodyJson().extractingPath("$.name").isEqualTo(metricName);
118+
assertThat(testResult).bodyJson().extractingPath("$.availableTags").asArray()
119+
.filteredOn("tag", tagName)
120+
.flatExtracting("values")
121+
.contains(uriVisited);
122+
}
123+
124+
@Test
125+
void endpointVisitsAreCounted() {
126+
// visit some endpoint a number of times
127+
final int visitCount = 3;
128+
for (int i = 0; i < visitCount; i++) {
129+
mockMvc.get().uri(uriVisited).exchange();
130+
}
131+
// get the available metrics for this endpoint
132+
URI endpointMetricsUri = metricsUriBuilder
133+
.pathSegment(metricName)
134+
.queryParam("tag", "%s:%s".formatted(tagName, uriVisited))
135+
.build().toUri();
136+
MvcTestResult testResult = mockMvc.get()
137+
.uri(endpointMetricsUri)
138+
.accept(MediaType.APPLICATION_JSON)
139+
.exchange();
140+
// the COUNT statistic value for this endpoint should match the actual number of visits
141+
assertThat(testResult).hasStatusOk().hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON);
142+
assertThat(testResult).bodyJson().extractingPath("$.name").isEqualTo(metricName);
143+
assertThat(testResult).bodyJson().extractingPath("$.measurements").asArray()
144+
.filteredOn("statistic", "COUNT")
145+
.extracting("value")
146+
.contains((double) visitCount);
147+
}
148+
}

0 commit comments

Comments
 (0)