From 6464d6f1ec7dea817e72b2e9f415dcc93e76cd5a Mon Sep 17 00:00:00 2001
From: Michael Hoennig <michael.hoennig@hostsharing.net>
Date: Fri, 6 Dec 2024 10:08:26 +0100
Subject: [PATCH] linked metrics-endpoint (#132)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/132
Reviewed-by: Timotheus Pokorra <timotheus.pokorra@hostsharing.net>
---
 .../config/CustomActuatorEndpoint.java        | 44 +++++++++++++++++++
 src/main/resources/application.yml            |  3 +-
 .../hsadminng/arch/ArchitectureTest.java      |  2 +
 .../CustomActuatorEndpointAcceptanceTest.java | 42 ++++++++++++++++++
 src/test/resources/application.yml            |  2 +-
 5 files changed, 91 insertions(+), 2 deletions(-)
 create mode 100644 src/main/java/net/hostsharing/hsadminng/config/CustomActuatorEndpoint.java
 create mode 100644 src/test/java/net/hostsharing/hsadminng/config/CustomActuatorEndpointAcceptanceTest.java

diff --git a/src/main/java/net/hostsharing/hsadminng/config/CustomActuatorEndpoint.java b/src/main/java/net/hostsharing/hsadminng/config/CustomActuatorEndpoint.java
new file mode 100644
index 00000000..942ddbb9
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/config/CustomActuatorEndpoint.java
@@ -0,0 +1,44 @@
+package net.hostsharing.hsadminng.config;
+
+import lombok.Getter;
+import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
+import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
+
+import java.util.List;
+
+@Component
+@Endpoint(id="metric-links")
+// BLOG: implement a custom Spring Actuator endpoint to view _clickable_ Spring Actuator (Micrometer) Metrics endpoints
+// HOWTO: implement a custom Spring Actuator endpoint
+public class CustomActuatorEndpoint {
+
+    private final RestTemplate restTemplate = new RestTemplate();
+
+    @ReadOperation
+    public String getMetricsLinks() {
+        final String baseUrl = ServletUriComponentsBuilder.fromCurrentContextPath().build().toUriString();
+        final var metricsEndpoint = baseUrl + "/actuator/metrics";
+
+        final var response = restTemplate.getForObject(metricsEndpoint, ActuatorMetricsEndpointResource.class);
+
+        if (response == null || response.getNames() == null) {
+            throw new IllegalStateException("no metrics available");
+        }
+        return generateJsonLinksToMetricEndpoints(response, metricsEndpoint);
+    }
+
+    private static String generateJsonLinksToMetricEndpoints(final ActuatorMetricsEndpointResource response, final String metricsEndpoint) {
+        final var links = response.getNames().stream()
+                .map(name -> "\"" + name + "\": \"" + metricsEndpoint + "/" + name + "\"")
+                .toList();
+        return "{\n" + String.join(",\n", links) + "\n}";
+    }
+
+    @Getter
+    private static class ActuatorMetricsEndpointResource {
+        private List<String> names;
+    }
+}
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index 27020234..f75ae429 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -8,7 +8,8 @@ management:
     endpoints:
         web:
             exposure:
-                include: info, health, metrics
+                # HOWTO: view _clickable_ Spring Actuator (Micrometer) Metrics endpoints: http://localhost:8081/actuator/metric-links
+                include: info, health, metrics, metric-links
     observations:
         annotations:
             enabled: true
diff --git a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java
index 2628ad5d..2bf87f09 100644
--- a/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/arch/ArchitectureTest.java
@@ -16,6 +16,7 @@ import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
 import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity;
 import net.hostsharing.hsadminng.rbac.context.ContextBasedTest;
 import net.hostsharing.hsadminng.rbac.grant.RbacGrantsDiagramService;
+import org.springframework.boot.test.context.SpringBootTest;
 import org.springframework.data.repository.Repository;
 import org.springframework.web.bind.annotation.RestController;
 
@@ -120,6 +121,7 @@ public class ArchitectureTest {
     @SuppressWarnings("unused")
     public static final ArchRule configPackageRule = classes()
             .that().resideInAPackage("..config..")
+            .and().areNotAnnotatedWith(SpringBootTest.class)
             .should().onlyDependOnClassesThat()
             .resideOutsideOfPackage(NET_HOSTSHARING_HSADMINNG);
 
diff --git a/src/test/java/net/hostsharing/hsadminng/config/CustomActuatorEndpointAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/config/CustomActuatorEndpointAcceptanceTest.java
new file mode 100644
index 00000000..1509831e
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/config/CustomActuatorEndpointAcceptanceTest.java
@@ -0,0 +1,42 @@
+package net.hostsharing.hsadminng.config;
+
+import io.restassured.RestAssured;
+import net.hostsharing.hsadminng.HsadminNgApplication;
+import net.hostsharing.hsadminng.test.DisableSecurityConfig;
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalManagementPort;
+import org.springframework.test.context.ActiveProfiles;
+
+import static net.hostsharing.hsadminng.rbac.test.JsonMatcher.lenientlyEquals;
+
+@SpringBootTest(
+        webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
+        classes = { HsadminNgApplication.class, DisableSecurityConfig.class }
+)
+@ActiveProfiles("test")
+class CustomActuatorEndpointAcceptanceTest {
+
+    @LocalManagementPort
+    private Integer managementPort;
+
+    @Test
+    void shouldListMetricLinks() {
+        RestAssured // @formatter:off
+                .given()
+                    .port(managementPort)
+                .when()
+                    .get("http://localhost/actuator/metric-links")
+                .then().log().all().assertThat()
+                    .statusCode(200)
+                    .contentType("application/vnd.spring-boot.actuator.v3+json")
+                    .body("", lenientlyEquals("""
+                    {
+                        "application.ready.time": "http://localhost:%{managementPort}/actuator/metrics/application.ready.time",
+                        "application.started.time": "http://localhost:%{managementPort}/actuator/metrics/application.started.time"
+                    }
+                    """.replace("%{managementPort}", managementPort.toString())));
+                // @formatter:on
+    }
+
+}
diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml
index a365daf3..f0df4e4b 100644
--- a/src/test/resources/application.yml
+++ b/src/test/resources/application.yml
@@ -6,7 +6,7 @@ management:
     endpoints:
         web:
             exposure:
-                include: info, health, metrics
+                include: info, health, metrics, metric-links
 
 spring:
     sql: