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 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: