diff --git a/lib/applicationinsights.json b/lib/applicationinsights.json index 88ff5ee711..e0128939f3 100644 --- a/lib/applicationinsights.json +++ b/lib/applicationinsights.json @@ -16,6 +16,17 @@ } ], "percentage": 1 + }, + { + "telemetryType": "request", + "attributes": [ + { + "key": "http.url", + "value": "https?://[^/]+/metrics/[^?]+\\?tag=cache:[^&]+$", + "matchType": "regexp" + } + ], + "percentage": 100 } ] } diff --git a/src/main/java/uk/gov/hmcts/ccd/SecurityConfiguration.java b/src/main/java/uk/gov/hmcts/ccd/SecurityConfiguration.java index ea5f0ea452..adfe8bd44d 100644 --- a/src/main/java/uk/gov/hmcts/ccd/SecurityConfiguration.java +++ b/src/main/java/uk/gov/hmcts/ccd/SecurityConfiguration.java @@ -67,7 +67,9 @@ public class SecurityConfiguration { "/webjars/**", "/v2/api-docs", "/v2/api-docs/**", - "/testing-support/cleanup-case-type/**" + "/testing-support/cleanup-case-type/**", + "/metrics", + "/metrics/**" }; @Inject @@ -106,7 +108,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .csrf(csrf -> csrf.disable()) // NOSONAR - CSRF is disabled purposely .formLogin(fl -> fl.disable()) .logout(logout -> logout.disable()) - .authorizeHttpRequests(auth -> + .authorizeHttpRequests(auth -> auth.requestMatchers("/error") .permitAll() .anyRequest() diff --git a/src/main/java/uk/gov/hmcts/ccd/config/CacheConfiguration.java b/src/main/java/uk/gov/hmcts/ccd/config/CacheConfiguration.java index fa675b989a..92c4275c7e 100644 --- a/src/main/java/uk/gov/hmcts/ccd/config/CacheConfiguration.java +++ b/src/main/java/uk/gov/hmcts/ccd/config/CacheConfiguration.java @@ -108,7 +108,7 @@ private CaffeineCache buildCache(String cacheName, int ttl, int maxIdle) { cacheBuilder.maximumSize(applicationParams.getDefaultCacheMaxSize()); log.debug("creating custom cache: name='{}'", cacheName); CaffeineCache caffeineCache = new CaffeineCache(cacheName, cacheBuilder.recordStats().build()); - caffeineCacheManager.registerCustomCache(cacheName, cacheBuilder.build()); + caffeineCacheManager.registerCustomCache(cacheName, caffeineCache.getNativeCache()); log.debug("registering custom cache: name='{}'", cacheName); return caffeineCache; diff --git a/src/main/java/uk/gov/hmcts/ccd/config/CaffeineCacheRateMetricsBinder.java b/src/main/java/uk/gov/hmcts/ccd/config/CaffeineCacheRateMetricsBinder.java new file mode 100644 index 0000000000..55a5378389 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/ccd/config/CaffeineCacheRateMetricsBinder.java @@ -0,0 +1,87 @@ +package uk.gov.hmcts.ccd.config; + +import io.micrometer.core.instrument.FunctionCounter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.MeterBinder; +import org.jspecify.annotations.NonNull; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.stereotype.Component; + +@Component +public class CaffeineCacheRateMetricsBinder implements MeterBinder { + + public static final String CACHE = "cache"; + private final CacheManager cacheManager; + + public CaffeineCacheRateMetricsBinder(CacheManager cacheManager) { + this.cacheManager = cacheManager; + } + + @Override + public void bindTo(@NonNull MeterRegistry registry) { + cacheManager.getCacheNames().forEach(cacheName -> { + Cache cache = cacheManager.getCache(cacheName); + if (cache instanceof CaffeineCache caffeineCache) { + com.github.benmanes.caffeine.cache.Cache nativeCache = caffeineCache.getNativeCache(); + + FunctionCounter.builder("cache.hits", nativeCache, c -> c.stats().hitCount()) + .description("The number of cache Hit Count") + .tag(CACHE, cacheName) + .register(registry); + + FunctionCounter.builder("cache.hits", nativeCache, c -> c.stats().hitCount()) + .description("The number of cache Hit Count") + .tag(CACHE, cacheName) + .register(registry); + + FunctionCounter.builder("cache.hits.rate", nativeCache, c -> c.stats().hitRate()) + .description("The number of cache Hit Rate") + .tag(CACHE, cacheName) + .register(registry); + + FunctionCounter.builder("cache.misses", nativeCache, c -> c.stats().missCount()) + .description("The number of cache Miss Count") + .tag(CACHE, cacheName) + .register(registry); + + FunctionCounter.builder("cache.misses.rate", nativeCache, c -> c.stats().missRate()) + .description("The number of cache miss rate") + .tag(CACHE, cacheName) + .register(registry); + + FunctionCounter.builder("cache.load.count", nativeCache, c -> c.stats().loadCount()) + .description("The number of cache load count") + .tag(CACHE, cacheName) + .register(registry); + + FunctionCounter.builder("cache.average.load.penalty", nativeCache, c -> c.stats().averageLoadPenalty()) + .description("The number of cache average load penalty") + .tag(CACHE, cacheName) + .register(registry); + + FunctionCounter.builder("cache.eviction.count", nativeCache, c -> c.stats().evictionCount()) + .description("The number of cache eviction count") + .tag(CACHE, cacheName) + .register(registry); + + FunctionCounter.builder("cache.eviction.weight", nativeCache, c -> c.stats().evictionWeight()) + .description("The number of cache eviction weight") + .tag(CACHE, cacheName) + .register(registry); + + FunctionCounter.builder("cache.request.count", nativeCache, c -> c.stats().requestCount()) + .description("The number of cache request count") + .tag(CACHE, cacheName) + .register(registry); + + FunctionCounter.builder("cache.puts", nativeCache, c -> c.stats().loadSuccessCount()) + .description("The number of cache puts (load success count)") + .tag(CACHE, cacheName) + .register(registry); + + } + }); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 67c9733f40..8aa994dc33 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -124,7 +124,7 @@ ccd.defaultPrintType=CCD Print Type management.server.servlet.context-path=/ # server under root instead of /actuator/* management.endpoints.web.base-path=/ -management.endpoints.web.exposure.include=health,info +management.endpoints.web.exposure.include=health,info,metrics # Explicitly disable the loggers actuator endpoint to prevent runtime log-level manipulation management.endpoint.loggers.enabled=false diff --git a/src/test/java/uk/gov/hmcts/ccd/ActuatorSecurityIT.java b/src/test/java/uk/gov/hmcts/ccd/ActuatorSecurityIT.java index 78b2109068..96999af51a 100644 --- a/src/test/java/uk/gov/hmcts/ccd/ActuatorSecurityIT.java +++ b/src/test/java/uk/gov/hmcts/ccd/ActuatorSecurityIT.java @@ -9,6 +9,7 @@ import org.springframework.web.context.WebApplicationContext; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; class ActuatorSecurityIT extends WireMockBaseTest { @@ -38,4 +39,64 @@ void shouldNotExposeLoggersEndpointAnonymously() throws Exception { assertTrue(status == 401 || status == 404, "Expected /loggers to be protected (401) or disabled (404), but got: " + status); } + + @Test + void shouldAllowAnonymousAccessToMetricsEndpoint() throws Exception { + mockMvc.perform(get("/metrics")) + .andExpect(status().isOk()); + } + + @Test + void shouldExposeCacheHitMissAndPutMetrics() throws Exception { + // Exercise a known cache to generate put/hit/miss stats. + var cache = cacheManager.getCache("userInfoCache"); + assertTrue(cache != null, "Expected userInfoCache to exist"); + + cache.put("metrics-test-key", "metrics-test-value"); + cache.get("metrics-test-key"); // hit + cache.get("metrics-test-miss"); // miss + + MvcResult getsMetricResult = mockMvc.perform(get("/metrics/cache.gets")) + .andExpect(status().isOk()) + .andReturn(); + String getsMetricResponse = getsMetricResult.getResponse().getContentAsString(); + assertTrue(getsMetricResponse.contains("\"result\""), + "Expected cache.gets metric to include hit/miss result tags"); + assertTrue(getsMetricResponse.contains("hit") || getsMetricResponse.contains("miss"), + "Expected cache.gets metric to include hit or miss tags"); + + mockMvc.perform(get("/metrics/cache.hits")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/metrics/cache.hits.rate")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/metrics/cache.misses")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/metrics/cache.misses.rate")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/metrics/cache.load.count")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/metrics/cache.average.load.penalty")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/metrics/cache.eviction.count")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/metrics/cache.eviction.weight")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/metrics/cache.request.count")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/metrics/cache.puts")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/metrics/cache.size")) + .andExpect(status().isOk()); + + } } diff --git a/src/test/java/uk/gov/hmcts/ccd/config/CaffeineCacheRateMetricsBinderTest.java b/src/test/java/uk/gov/hmcts/ccd/config/CaffeineCacheRateMetricsBinderTest.java new file mode 100644 index 0000000000..2c81a759c2 --- /dev/null +++ b/src/test/java/uk/gov/hmcts/ccd/config/CaffeineCacheRateMetricsBinderTest.java @@ -0,0 +1,114 @@ +package uk.gov.hmcts.ccd.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.stats.CacheStats; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.cache.caffeine.CaffeineCacheManager; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; + + +@DisplayName("CaffeineCacheRateMetricsBinder") +class CaffeineCacheRateMetricsBinderTest { + + public static final String CACHE_ONE = "cacheOne"; + public static final String CACHE_TWO = "cacheTwo"; + public static final String CACHE = "cache"; + private static final double DELTA = 0.000001D; + + private CaffeineCacheRateMetricsBinder classUnderTest; + private CaffeineCacheManager cacheManager; + + @BeforeEach + void setUp() { + cacheManager = new CaffeineCacheManager(); + classUnderTest = new CaffeineCacheRateMetricsBinder(cacheManager); + } + + @Test + @DisplayName("should register all supported metrics") + void shouldRegisterAllMetrics() { + cacheManager.setCaffeine(Caffeine.newBuilder().maximumSize(1).recordStats()); + cacheManager.setCacheNames(List.of(CACHE_ONE, CACHE_TWO)); + + final CaffeineCache cacheOne = (CaffeineCache) cacheManager.getCache(CACHE_ONE); + final CaffeineCache cacheTwo = (CaffeineCache) cacheManager.getCache(CACHE_TWO); + + cacheOne.put("k1", "v1"); + cacheOne.get("k1"); + cacheOne.get("missing"); + cacheOne.get("load", () -> "loaded"); + cacheOne.get("load"); + cacheOne.put("evict-1", "value-1"); + cacheOne.put("evict-2", "value-2"); + + cacheTwo.put("k2", "v2"); + cacheTwo.get("k2"); + cacheTwo.get("missing"); + cacheTwo.get("load", () -> "loaded"); + cacheTwo.get("load"); + cacheTwo.put("evict-3", "value-3"); + cacheTwo.put("evict-4", "value-4"); + + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + classUnderTest.bindTo(registry); + + CacheStats cacheOneStats = cacheOne.getNativeCache().stats(); + CacheStats cacheTwoStats = cacheTwo.getNativeCache().stats(); + + assertAll( + () -> assertEquals(cacheOneStats.hitCount(), metricValue(registry, "cache.hits", CACHE_ONE), DELTA), + () -> assertEquals(cacheTwoStats.hitCount(), metricValue(registry, "cache.hits", CACHE_TWO), DELTA), + () -> assertEquals(cacheOneStats.hitRate(), metricValue(registry, "cache.hits.rate", CACHE_ONE), DELTA), + () -> assertEquals(cacheTwoStats.hitRate(), metricValue(registry, "cache.hits.rate", CACHE_TWO), DELTA), + () -> assertEquals(cacheOneStats.missCount(), metricValue(registry, "cache.misses", CACHE_ONE), DELTA), + () -> assertEquals(cacheTwoStats.missCount(), metricValue(registry, "cache.misses", CACHE_TWO), DELTA), + () -> assertEquals(cacheOneStats.missRate(), metricValue(registry, "cache.misses.rate", CACHE_ONE), DELTA), + () -> assertEquals(cacheTwoStats.missRate(), metricValue(registry, "cache.misses.rate", CACHE_TWO), DELTA), + () -> assertEquals(cacheOneStats.loadCount(), metricValue(registry, "cache.load.count", CACHE_ONE), DELTA), + () -> assertEquals(cacheTwoStats.loadCount(), metricValue(registry, "cache.load.count", CACHE_TWO), DELTA), + () -> assertEquals(cacheOneStats.averageLoadPenalty(), + metricValue(registry, "cache.average.load.penalty", CACHE_ONE), DELTA), + + () -> assertEquals(cacheTwoStats.averageLoadPenalty(), + metricValue(registry, "cache.average.load.penalty", CACHE_TWO), DELTA), + + () -> assertEquals(cacheOneStats.evictionCount(), + metricValue(registry, "cache.eviction.count", CACHE_ONE), DELTA), + + () -> assertEquals(cacheTwoStats.evictionCount(), + metricValue(registry, "cache.eviction.count", CACHE_TWO), DELTA), + + () -> assertEquals(cacheOneStats.evictionWeight(), + metricValue(registry, "cache.eviction.weight", CACHE_ONE), DELTA), + + () -> assertEquals(cacheTwoStats.evictionWeight(), + metricValue(registry, "cache.eviction.weight", CACHE_TWO), DELTA), + () -> assertEquals(cacheOneStats.requestCount(), + metricValue(registry, "cache.request.count", CACHE_ONE), DELTA), + + () -> assertEquals(cacheTwoStats.requestCount(), + metricValue(registry, "cache.request.count", CACHE_TWO), DELTA), + + () -> assertEquals(cacheOneStats.loadSuccessCount(), + metricValue(registry, "cache.puts", CACHE_ONE), DELTA), + + () -> assertEquals(cacheTwoStats.loadSuccessCount(), + metricValue(registry, "cache.puts", CACHE_TWO), DELTA), + // Duplicate cache.hits registration should still resolve to a single meter per cache tag. + () -> assertEquals(20, registry.getMeters().size()) + ); + } + + private double metricValue(SimpleMeterRegistry registry, String name, String cacheName) { + return registry.get(name).tag(CACHE, cacheName).functionCounter().count(); + } +}