Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions lib/applicationinsights.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@
}
],
"percentage": 1
},
{
"telemetryType": "request",
"attributes": [
{
"key": "http.url",
"value": "https?://[^/]+/metrics/[^?]+\\?tag=cache:[^&]+$",
"matchType": "regexp"
}
],
"percentage": 100
}
]
}
Expand Down
6 changes: 4 additions & 2 deletions src/main/java/uk/gov/hmcts/ccd/SecurityConfiguration.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);

}
});
}
}
2 changes: 1 addition & 1 deletion src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions src/test/java/uk/gov/hmcts/ccd/ActuatorSecurityIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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());

}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}