From 3936436706fd51141603797e88d4a21e967bceb9 Mon Sep 17 00:00:00 2001 From: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> Date: Sat, 9 May 2026 01:43:19 +0900 Subject: [PATCH 1/9] Enable Caffeine cache stats recording for LocalResponseCache See gh-3722 Signed-off-by: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> --- .../gateway/filter/factory/cache/LocalResponseCacheUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtils.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtils.java index fc7224adb..4e4c163df 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtils.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtils.java @@ -48,7 +48,7 @@ public static CaffeineCacheManager createGatewayCacheManager(LocalResponseCacheP @SuppressWarnings({ "unchecked", "rawtypes" }) public static Caffeine createCaffeine(LocalResponseCacheProperties cacheProperties) { - Caffeine caffeine = Caffeine.newBuilder(); + Caffeine caffeine = Caffeine.newBuilder().recordStats(); LOGGER.info("Initializing Caffeine"); Duration ttlSeconds = cacheProperties.getTimeToLive(); caffeine.expireAfterWrite(ttlSeconds); From e66ed4f19add2c9feb47b8443c1c981b5719ffb5 Mon Sep 17 00:00:00 2001 From: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> Date: Sat, 9 May 2026 01:43:19 +0900 Subject: [PATCH 2/9] Add CacheMetricsListener callback to LocalResponseCacheGatewayFilterFactory See gh-3722 Signed-off-by: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> --- .../LocalResponseCacheAutoConfiguration.java | 22 +++++++--- ...ocalResponseCacheGatewayFilterFactory.java | 41 ++++++++++++++++++- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java index 752295d5c..c3612ed42 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java @@ -21,6 +21,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.AllNestedConditions; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -28,10 +29,12 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCache; import org.springframework.cache.caffeine.CaffeineCacheManager; import org.springframework.cloud.gateway.config.conditional.ConditionalOnEnabledFilter; import org.springframework.cloud.gateway.filter.factory.cache.GlobalLocalResponseCacheGatewayFilter; import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory.CacheMetricsListener; import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheProperties; import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheUtils; import org.springframework.cloud.gateway.filter.factory.cache.ResponseCacheManagerFactory; @@ -60,10 +63,15 @@ public class LocalResponseCacheAutoConfiguration { @Conditional(LocalResponseCacheAutoConfiguration.OnGlobalLocalResponseCacheCondition.class) public GlobalLocalResponseCacheGatewayFilter globalLocalResponseCacheGatewayFilter( ResponseCacheManagerFactory responseCacheManagerFactory, - @Qualifier(RESPONSE_CACHE_MANAGER_NAME) CacheManager cacheManager, - LocalResponseCacheProperties properties) { - return new GlobalLocalResponseCacheGatewayFilter(responseCacheManagerFactory, responseCache(cacheManager), - properties.getTimeToLive(), properties.getRequest()); + @Qualifier(RESPONSE_CACHE_MANAGER_NAME) CacheManager cacheManager, LocalResponseCacheProperties properties, + ObjectProvider metricsListenerProvider) { + Cache cache = responseCache(cacheManager); + CacheMetricsListener listener = metricsListenerProvider.getIfAvailable(() -> CacheMetricsListener.NOOP); + if (cache instanceof CaffeineCache caffeineCache) { + listener.onCacheCreated(caffeineCache.getNativeCache(), RESPONSE_CACHE_NAME); + } + return new GlobalLocalResponseCacheGatewayFilter(responseCacheManagerFactory, cache, properties.getTimeToLive(), + properties.getRequest()); } @Bean(name = RESPONSE_CACHE_MANAGER_NAME) @@ -74,9 +82,11 @@ public CacheManager gatewayCacheManager(LocalResponseCacheProperties cacheProper @Bean public LocalResponseCacheGatewayFilterFactory localResponseCacheGatewayFilterFactory( - ResponseCacheManagerFactory responseCacheManagerFactory, LocalResponseCacheProperties properties) { + ResponseCacheManagerFactory responseCacheManagerFactory, LocalResponseCacheProperties properties, + ObjectProvider metricsListenerProvider) { + CacheMetricsListener listener = metricsListenerProvider.getIfAvailable(() -> CacheMetricsListener.NOOP); return new LocalResponseCacheGatewayFilterFactory(responseCacheManagerFactory, properties.getTimeToLive(), - properties.getSize(), properties.getRequest()); + properties.getSize(), properties.getRequest(), listener); } @Bean diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java index 95fa4e8d7..977373a3f 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java @@ -63,20 +63,38 @@ public class LocalResponseCacheGatewayFilterFactory private final CaffeineCacheManager caffeineCacheManager; + private final CacheMetricsListener cacheMetricsListener; + public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory, Duration defaultTimeToLive, DataSize defaultSize, RequestOptions requestOptions) { - this(cacheManagerFactory, defaultTimeToLive, defaultSize, requestOptions, new CaffeineCacheManager()); + this(cacheManagerFactory, defaultTimeToLive, defaultSize, requestOptions, new CaffeineCacheManager(), + CacheMetricsListener.NOOP); } public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory, Duration defaultTimeToLive, DataSize defaultSize, RequestOptions requestOptions, CaffeineCacheManager caffeineCacheManager) { + this(cacheManagerFactory, defaultTimeToLive, defaultSize, requestOptions, caffeineCacheManager, + CacheMetricsListener.NOOP); + } + + public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory, + Duration defaultTimeToLive, DataSize defaultSize, RequestOptions requestOptions, + CacheMetricsListener cacheMetricsListener) { + this(cacheManagerFactory, defaultTimeToLive, defaultSize, requestOptions, new CaffeineCacheManager(), + cacheMetricsListener); + } + + public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory, + Duration defaultTimeToLive, DataSize defaultSize, RequestOptions requestOptions, + CaffeineCacheManager caffeineCacheManager, CacheMetricsListener cacheMetricsListener) { super(RouteCacheConfiguration.class); this.cacheManagerFactory = cacheManagerFactory; this.defaultTimeToLive = defaultTimeToLive; this.defaultSize = defaultSize; this.requestOptions = requestOptions; this.caffeineCacheManager = caffeineCacheManager; + this.cacheMetricsListener = cacheMetricsListener; } @Override @@ -86,7 +104,9 @@ public GatewayFilter apply(RouteCacheConfiguration config) { Caffeine caffeine = LocalResponseCacheUtils.createCaffeine(cacheProperties); String cacheName = config.getRouteId() + "-cache"; - caffeineCacheManager.registerCustomCache(cacheName, caffeine.build()); + com.github.benmanes.caffeine.cache.Cache nativeCache = caffeine.build(); + caffeineCacheManager.registerCustomCache(cacheName, nativeCache); + cacheMetricsListener.onCacheCreated(nativeCache, cacheName); Cache routeCache = caffeineCacheManager.getCache(cacheName); Objects.requireNonNull(routeCache, "Cache " + cacheName + " not found"); return new ResponseCacheGatewayFilter( @@ -109,6 +129,23 @@ public List shortcutFieldOrder() { return List.of("timeToLive", "size"); } + /** + * Listener notified when a new Caffeine cache is created, allowing external + * components (e.g., metrics) to observe cache instances. + */ + @FunctionalInterface + public interface CacheMetricsListener { + + void onCacheCreated(com.github.benmanes.caffeine.cache.Cache cache, String cacheName); + + /** + * No-op implementation used when metrics infrastructure is not available. + */ + CacheMetricsListener NOOP = (cache, cacheName) -> { + }; + + } + @Validated public static class RouteCacheConfiguration implements HasRouteId { From 173fd044247cc0df0a930bfdc59a10206418c009 Mon Sep 17 00:00:00 2001 From: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> Date: Sat, 9 May 2026 01:43:19 +0900 Subject: [PATCH 3/9] Add auto-configuration for LocalResponseCache metrics Fixes gh-3722 Signed-off-by: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> --- ...ResponseCacheMetricsAutoConfiguration.java | 57 ++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...nseCacheMetricsAutoConfigurationTests.java | 127 ++++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java create mode 100644 spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java new file mode 100644 index 000000000..d23ac54fa --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java @@ -0,0 +1,57 @@ +/* + * Copyright 2013-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.config; + +import java.util.Collections; + +import com.github.benmanes.caffeine.cache.Caffeine; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics; + +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.micrometer.metrics.autoconfigure.CompositeMeterRegistryAutoConfiguration; +import org.springframework.boot.micrometer.metrics.autoconfigure.MetricsAutoConfiguration; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory.CacheMetricsListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Auto-configuration for LocalResponseCache metrics. Registers Caffeine cache metrics + * with the {@link MeterRegistry} when both the cache infrastructure and Micrometer are + * available. + * + * @author LivingLikeKrillin + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass({ Caffeine.class, CaffeineCacheManager.class, MeterRegistry.class, MetricsAutoConfiguration.class }) +@ConditionalOnBean(MeterRegistry.class) +@ConditionalOnProperty(name = GatewayProperties.PREFIX + ".metrics.enabled", matchIfMissing = true) +@AutoConfigureAfter({ LocalResponseCacheAutoConfiguration.class, MetricsAutoConfiguration.class, + CompositeMeterRegistryAutoConfiguration.class }) +public class LocalResponseCacheMetricsAutoConfiguration { + + @Bean + CacheMetricsListener localResponseCacheMetricsListener(MeterRegistry meterRegistry) { + return (cache, cacheName) -> CaffeineCacheMetrics.monitor(meterRegistry, cache, cacheName, + Collections.emptyList()); + } + +} diff --git a/spring-cloud-gateway-server-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-cloud-gateway-server-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 639d71a0b..df9533bee 100644 --- a/spring-cloud-gateway-server-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-cloud-gateway-server-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -10,4 +10,5 @@ org.springframework.cloud.gateway.discovery.GatewayDiscoveryClientAutoConfigurat org.springframework.cloud.gateway.config.SimpleUrlHandlerMappingGlobalCorsAutoConfiguration org.springframework.cloud.gateway.config.GatewayReactiveLoadBalancerClientAutoConfiguration org.springframework.cloud.gateway.config.LocalResponseCacheAutoConfiguration +org.springframework.cloud.gateway.config.LocalResponseCacheMetricsAutoConfiguration org.springframework.cloud.gateway.config.GatewayTracingAutoConfiguration diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java new file mode 100644 index 000000000..12b2a92e1 --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java @@ -0,0 +1,127 @@ +/* + * Copyright 2013-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.config; + +import java.time.Duration; + +import com.github.benmanes.caffeine.cache.Caffeine; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory.CacheMetricsListener; +import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheProperties; +import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link LocalResponseCacheMetricsAutoConfiguration}. + * + * @author LivingLikeKrillin + */ +class LocalResponseCacheMetricsAutoConfigurationTests { + + @Test + void metricsListenerCreatedWhenMeterRegistryPresent() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LocalResponseCacheAutoConfiguration.class, + LocalResponseCacheMetricsAutoConfiguration.class)) + .withUserConfiguration(MeterRegistryConfig.class) + .withPropertyValues(GatewayProperties.PREFIX + ".filter.local-response-cache.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(CacheMetricsListener.class); + }); + } + + @Test + void metricsListenerNotCreatedWhenMeterRegistryAbsent() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LocalResponseCacheAutoConfiguration.class, + LocalResponseCacheMetricsAutoConfiguration.class)) + .withPropertyValues(GatewayProperties.PREFIX + ".filter.local-response-cache.enabled=true") + .run(context -> { + assertThat(context).doesNotHaveBean(CacheMetricsListener.class); + }); + } + + @Test + void metricsListenerNotCreatedWhenMetricsDisabled() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LocalResponseCacheAutoConfiguration.class, + LocalResponseCacheMetricsAutoConfiguration.class)) + .withUserConfiguration(MeterRegistryConfig.class) + .withPropertyValues(GatewayProperties.PREFIX + ".filter.local-response-cache.enabled=true", + GatewayProperties.PREFIX + ".metrics.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean(CacheMetricsListener.class); + }); + } + + @Test + void caffeineRecordStatsEnabled() { + LocalResponseCacheProperties properties = new LocalResponseCacheProperties(); + properties.setTimeToLive(Duration.ofMinutes(5)); + Caffeine caffeine = LocalResponseCacheUtils.createCaffeine(properties); + com.github.benmanes.caffeine.cache.Cache cache = caffeine.build(); + + cache.put("key", "value"); + cache.getIfPresent("key"); + cache.getIfPresent("missing"); + + assertThat(cache.stats().hitCount()).isEqualTo(1); + assertThat(cache.stats().missCount()).isEqualTo(1); + } + + @Test + void cacheMetricsListenerBindsToMeterRegistry() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + LocalResponseCacheProperties properties = new LocalResponseCacheProperties(); + properties.setTimeToLive(Duration.ofMinutes(5)); + Caffeine caffeine = LocalResponseCacheUtils.createCaffeine(properties); + com.github.benmanes.caffeine.cache.Cache cache = caffeine.build(); + + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LocalResponseCacheMetricsAutoConfiguration.class)) + .withBean(MeterRegistry.class, () -> registry) + .run(context -> { + CacheMetricsListener listener = context.getBean(CacheMetricsListener.class); + listener.onCacheCreated(cache, "test-cache"); + + cache.put("key", "value"); + cache.getIfPresent("key"); + + assertThat(registry.find("cache.gets").tag("result", "hit").functionCounter()).isNotNull(); + assertThat(registry.find("cache.size").tag("cache", "test-cache").gauge()).isNotNull(); + }); + } + + @Configuration(proxyBeanMethods = false) + static class MeterRegistryConfig { + + @Bean + MeterRegistry meterRegistry() { + return new SimpleMeterRegistry(); + } + + } + +} From 18c3a683e817fbc364ad02bbb7609a152d3defae Mon Sep 17 00:00:00 2001 From: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> Date: Sat, 9 May 2026 01:43:20 +0900 Subject: [PATCH 4/9] Polish LocalResponseCache metrics implementation - Remove unused constructor overload in LocalResponseCacheGatewayFilterFactory - Tighten recordStats() comment - Align bean method visibility with existing conventions Signed-off-by: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> --- .../LocalResponseCacheAutoConfiguration.java | 6 +----- ...LocalResponseCacheMetricsAutoConfiguration.java | 9 +-------- .../LocalResponseCacheGatewayFilterFactory.java | 14 -------------- .../factory/cache/LocalResponseCacheUtils.java | 1 + 4 files changed, 3 insertions(+), 27 deletions(-) diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java index c3612ed42..3d313133d 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheAutoConfiguration.java @@ -18,8 +18,6 @@ import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.Weigher; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Qualifier; @@ -53,8 +51,6 @@ @ConditionalOnEnabledFilter(LocalResponseCacheGatewayFilterFactory.class) public class LocalResponseCacheAutoConfiguration { - private static final Log LOGGER = LogFactory.getLog(LocalResponseCacheAutoConfiguration.class); - private static final String RESPONSE_CACHE_NAME = "response-cache"; /* for testing */ static final String RESPONSE_CACHE_MANAGER_NAME = "gatewayCacheManager"; @@ -86,7 +82,7 @@ public LocalResponseCacheGatewayFilterFactory localResponseCacheGatewayFilterFac ObjectProvider metricsListenerProvider) { CacheMetricsListener listener = metricsListenerProvider.getIfAvailable(() -> CacheMetricsListener.NOOP); return new LocalResponseCacheGatewayFilterFactory(responseCacheManagerFactory, properties.getTimeToLive(), - properties.getSize(), properties.getRequest(), listener); + properties.getSize(), properties.getRequest(), new CaffeineCacheManager(), listener); } @Bean diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java index d23ac54fa..cc01dac7d 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java @@ -33,13 +33,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -/** - * Auto-configuration for LocalResponseCache metrics. Registers Caffeine cache metrics - * with the {@link MeterRegistry} when both the cache infrastructure and Micrometer are - * available. - * - * @author LivingLikeKrillin - */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass({ Caffeine.class, CaffeineCacheManager.class, MeterRegistry.class, MetricsAutoConfiguration.class }) @ConditionalOnBean(MeterRegistry.class) @@ -49,7 +42,7 @@ public class LocalResponseCacheMetricsAutoConfiguration { @Bean - CacheMetricsListener localResponseCacheMetricsListener(MeterRegistry meterRegistry) { + public CacheMetricsListener localResponseCacheMetricsListener(MeterRegistry meterRegistry) { return (cache, cacheName) -> CaffeineCacheMetrics.monitor(meterRegistry, cache, cacheName, Collections.emptyList()); } diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java index 977373a3f..24e5227e2 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java @@ -78,13 +78,6 @@ public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheM CacheMetricsListener.NOOP); } - public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory, - Duration defaultTimeToLive, DataSize defaultSize, RequestOptions requestOptions, - CacheMetricsListener cacheMetricsListener) { - this(cacheManagerFactory, defaultTimeToLive, defaultSize, requestOptions, new CaffeineCacheManager(), - cacheMetricsListener); - } - public LocalResponseCacheGatewayFilterFactory(ResponseCacheManagerFactory cacheManagerFactory, Duration defaultTimeToLive, DataSize defaultSize, RequestOptions requestOptions, CaffeineCacheManager caffeineCacheManager, CacheMetricsListener cacheMetricsListener) { @@ -129,18 +122,11 @@ public List shortcutFieldOrder() { return List.of("timeToLive", "size"); } - /** - * Listener notified when a new Caffeine cache is created, allowing external - * components (e.g., metrics) to observe cache instances. - */ @FunctionalInterface public interface CacheMetricsListener { void onCacheCreated(com.github.benmanes.caffeine.cache.Cache cache, String cacheName); - /** - * No-op implementation used when metrics infrastructure is not available. - */ CacheMetricsListener NOOP = (cache, cacheName) -> { }; diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtils.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtils.java index 4e4c163df..e094c3726 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtils.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheUtils.java @@ -48,6 +48,7 @@ public static CaffeineCacheManager createGatewayCacheManager(LocalResponseCacheP @SuppressWarnings({ "unchecked", "rawtypes" }) public static Caffeine createCaffeine(LocalResponseCacheProperties cacheProperties) { + // Record stats unconditionally; LongAdder overhead is negligible. Caffeine caffeine = Caffeine.newBuilder().recordStats(); LOGGER.info("Initializing Caffeine"); Duration ttlSeconds = cacheProperties.getTimeToLive(); From d30d274836a4b27347e3ca387d70eaa2c9ccbb51 Mon Sep 17 00:00:00 2001 From: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> Date: Sat, 9 May 2026 01:43:20 +0900 Subject: [PATCH 5/9] Add test for global cache metrics registration path Verify that CaffeineCacheMetrics are registered for the global response-cache when both MeterRegistry and global filter are enabled. Signed-off-by: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> --- ...sponseCacheMetricsAutoConfigurationTests.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java index 12b2a92e1..fe48c6fa3 100644 --- a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java @@ -114,6 +114,22 @@ void cacheMetricsListenerBindsToMeterRegistry() { }); } + @Test + void globalCacheMetricsRegisteredViaCaffeineCache() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LocalResponseCacheAutoConfiguration.class, + LocalResponseCacheMetricsAutoConfiguration.class)) + .withBean(MeterRegistry.class, () -> registry) + .withPropertyValues(GatewayProperties.PREFIX + ".filter.local-response-cache.enabled=true", + GatewayProperties.PREFIX + ".enabled=true", + GatewayProperties.PREFIX + ".global-filter.local-response-cache.enabled=true") + .run(context -> { + assertThat(context).hasSingleBean(CacheMetricsListener.class); + assertThat(registry.find("cache.size").tag("cache", "response-cache").gauge()).isNotNull(); + }); + } + @Configuration(proxyBeanMethods = false) static class MeterRegistryConfig { From 4cbb13d1beb44d07cbe96f9ed2022c0f5f5abc64 Mon Sep 17 00:00:00 2001 From: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> Date: Sat, 9 May 2026 01:43:21 +0900 Subject: [PATCH 6/9] Align metrics auto-configuration with gateway conventions - Reorder class-level annotations to match GatewayMetricsAutoConfiguration - Add DispatcherHandler to @ConditionalOnClass for WebFlux environment guard - Move @ConditionalOnBean and metrics property check to bean method level - Make test class public to match existing test conventions - Add test for gateway disabled scenario - Add per-route cache metrics integration test See gh-3722 Signed-off-by: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> --- ...ResponseCacheMetricsAutoConfiguration.java | 9 +++-- ...nseCacheMetricsAutoConfigurationTests.java | 39 ++++++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java index cc01dac7d..154592a30 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java @@ -32,16 +32,19 @@ import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory.CacheMetricsListener; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.DispatcherHandler; @Configuration(proxyBeanMethods = false) -@ConditionalOnClass({ Caffeine.class, CaffeineCacheManager.class, MeterRegistry.class, MetricsAutoConfiguration.class }) -@ConditionalOnBean(MeterRegistry.class) -@ConditionalOnProperty(name = GatewayProperties.PREFIX + ".metrics.enabled", matchIfMissing = true) +@ConditionalOnProperty(name = GatewayProperties.PREFIX + ".enabled", matchIfMissing = true) @AutoConfigureAfter({ LocalResponseCacheAutoConfiguration.class, MetricsAutoConfiguration.class, CompositeMeterRegistryAutoConfiguration.class }) +@ConditionalOnClass({ DispatcherHandler.class, Caffeine.class, CaffeineCacheManager.class, MeterRegistry.class, + MetricsAutoConfiguration.class }) public class LocalResponseCacheMetricsAutoConfiguration { @Bean + @ConditionalOnBean(MeterRegistry.class) + @ConditionalOnProperty(name = GatewayProperties.PREFIX + ".metrics.enabled", matchIfMissing = true) public CacheMetricsListener localResponseCacheMetricsListener(MeterRegistry meterRegistry) { return (cache, cacheName) -> CaffeineCacheMetrics.monitor(meterRegistry, cache, cacheName, Collections.emptyList()); diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java index fe48c6fa3..6db7dd96a 100644 --- a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java @@ -25,9 +25,13 @@ import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheGatewayFilterFactory.CacheMetricsListener; import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheProperties; import org.springframework.cloud.gateway.filter.factory.cache.LocalResponseCacheUtils; +import org.springframework.cloud.gateway.filter.factory.cache.ResponseCacheManagerFactory; +import org.springframework.cloud.gateway.filter.factory.cache.keygenerator.CacheKeyGenerator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -38,7 +42,7 @@ * * @author LivingLikeKrillin */ -class LocalResponseCacheMetricsAutoConfigurationTests { +public class LocalResponseCacheMetricsAutoConfigurationTests { @Test void metricsListenerCreatedWhenMeterRegistryPresent() { @@ -63,6 +67,19 @@ void metricsListenerNotCreatedWhenMeterRegistryAbsent() { }); } + @Test + void metricsListenerNotCreatedWhenGatewayDisabled() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LocalResponseCacheAutoConfiguration.class, + LocalResponseCacheMetricsAutoConfiguration.class)) + .withUserConfiguration(MeterRegistryConfig.class) + .withPropertyValues(GatewayProperties.PREFIX + ".filter.local-response-cache.enabled=true", + GatewayProperties.PREFIX + ".enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean(CacheMetricsListener.class); + }); + } + @Test void metricsListenerNotCreatedWhenMetricsDisabled() { new ApplicationContextRunner() @@ -130,6 +147,26 @@ void globalCacheMetricsRegisteredViaCaffeineCache() { }); } + @Test + void perRouteCacheMetricsRegisteredViaFilterFactory() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + CacheMetricsListener listener = (cache, + cacheName) -> io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics.monitor(registry, cache, + cacheName, java.util.Collections.emptyList()); + + Duration ttl = Duration.ofMinutes(5); + ResponseCacheManagerFactory cacheManagerFactory = new ResponseCacheManagerFactory(new CacheKeyGenerator()); + LocalResponseCacheGatewayFilterFactory factory = new LocalResponseCacheGatewayFilterFactory(cacheManagerFactory, + ttl, null, new LocalResponseCacheProperties.RequestOptions(), new CaffeineCacheManager(), listener); + + LocalResponseCacheGatewayFilterFactory.RouteCacheConfiguration routeConfig = new LocalResponseCacheGatewayFilterFactory.RouteCacheConfiguration(); + routeConfig.setRouteId("my-route"); + routeConfig.setTimeToLive(ttl); + factory.apply(routeConfig); + + assertThat(registry.find("cache.size").tag("cache", "my-route-cache").gauge()).isNotNull(); + } + @Configuration(proxyBeanMethods = false) static class MeterRegistryConfig { From e0cdf86d94f78c5a19b7fd8e27b7b0191baab17b Mon Sep 17 00:00:00 2001 From: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> Date: Fri, 29 May 2026 21:38:53 +0900 Subject: [PATCH 7/9] Keep LocalResponseCache metrics tracking the current cache across refreshes LocalResponseCacheGatewayFilterFactory.apply is re-invoked on every RefreshRoutesEvent and builds a new Caffeine cache that replaces the previous one in CaffeineCacheManager. The old auto-configuration wired CaffeineCacheMetrics.monitor directly, so the second invocation hit MeterRegistry's duplicate-id check, was silently dropped, and the gauges stayed bound to the discarded cache - reporting NaN once it was garbage-collected. Replace the lambda listener with SwappableCacheMetricsListener: each cacheName is bound to the registry once via a custom CacheMeterBinder backed by a mutable AtomicReference; subsequent onCacheCreated calls swap the underlying Caffeine cache without re-registering meters. Tighten the CacheMetricsListener contract javadoc to state that implementations must be safe to invoke multiple times for the same cacheName. Signed-off-by: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> --- ...ResponseCacheMetricsAutoConfiguration.java | 110 +++++++++++++++++- ...ocalResponseCacheGatewayFilterFactory.java | 23 ++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java index 154592a30..0ef21237e 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java @@ -16,11 +16,17 @@ package org.springframework.cloud.gateway.config; -import java.util.Collections; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import io.micrometer.core.instrument.MeterRegistry; -import io.micrometer.core.instrument.binder.cache.CaffeineCacheMetrics; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.binder.cache.CacheMeterBinder; +import org.jspecify.annotations.Nullable; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; @@ -34,6 +40,13 @@ import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.DispatcherHandler; +/** + * Auto-configuration that exposes cache statistics for {@code LocalResponseCache} as + * Micrometer meters. Registers a {@link CacheMetricsListener} that binds each Caffeine + * cache to the {@link MeterRegistry} the first time it is created and rebinds the + * underlying reference on subsequent route refreshes so the gauges keep tracking the + * current cache instance. + */ @Configuration(proxyBeanMethods = false) @ConditionalOnProperty(name = GatewayProperties.PREFIX + ".enabled", matchIfMissing = true) @AutoConfigureAfter({ LocalResponseCacheAutoConfiguration.class, MetricsAutoConfiguration.class, @@ -46,8 +59,97 @@ public class LocalResponseCacheMetricsAutoConfiguration { @ConditionalOnBean(MeterRegistry.class) @ConditionalOnProperty(name = GatewayProperties.PREFIX + ".metrics.enabled", matchIfMissing = true) public CacheMetricsListener localResponseCacheMetricsListener(MeterRegistry meterRegistry) { - return (cache, cacheName) -> CaffeineCacheMetrics.monitor(meterRegistry, cache, cacheName, - Collections.emptyList()); + return new SwappableCacheMetricsListener(meterRegistry); + } + + /** + * Listener that binds each cache name to the registry once and swaps the underlying + * cache reference on subsequent invocations. + * + *

+ * {@link MeterRegistry} silently drops duplicate registrations - calling + * {@code CaffeineCacheMetrics.monitor(...)} a second time with a new cache instance + * returns the existing meter, leaving the gauges bound to the original (already + * replaced) cache. Because {@code LocalResponseCacheGatewayFilterFactory.apply} is + * re-invoked on every route refresh and builds a new Caffeine cache each time, naive + * registration would leave the metrics permanently tracking a discarded cache and + * reporting {@code NaN} once it is garbage-collected. + */ + static class SwappableCacheMetricsListener implements CacheMetricsListener { + + private final MeterRegistry registry; + + private final Map>> refsByCacheName = new ConcurrentHashMap<>(); + + SwappableCacheMetricsListener(MeterRegistry registry) { + this.registry = registry; + } + + @Override + public void onCacheCreated(Cache cache, String cacheName) { + refsByCacheName.computeIfAbsent(cacheName, name -> { + AtomicReference> ref = new AtomicReference<>(cache); + new SwappableCaffeineCacheMetrics(ref, name, Tags.empty()).bindTo(registry); + return ref; + }).set(cache); + } + + } + + /** + * Cache meter binder bound to a mutable {@link AtomicReference} so the registered + * gauges always read whichever Caffeine cache instance is currently set on the + * reference. Exposes the standard {@link CacheMeterBinder} meter set + * ({@code cache.size}, {@code cache.gets}, {@code cache.puts}, + * {@code cache.evictions}); Caffeine-specific meters are intentionally omitted to + * keep the refresh-safe path simple. + */ + static class SwappableCaffeineCacheMetrics extends CacheMeterBinder>> { + + SwappableCaffeineCacheMetrics(AtomicReference> ref, String cacheName, Iterable tags) { + super(ref, cacheName, tags); + } + + @Override + protected @Nullable Long size() { + Cache c = current(); + return c != null ? c.estimatedSize() : null; + } + + @Override + protected long hitCount() { + Cache c = current(); + return c != null ? c.stats().hitCount() : 0L; + } + + @Override + protected @Nullable Long missCount() { + Cache c = current(); + return c != null ? c.stats().missCount() : null; + } + + @Override + protected @Nullable Long evictionCount() { + Cache c = current(); + return c != null ? c.stats().evictionCount() : null; + } + + @Override + protected long putCount() { + Cache c = current(); + return c != null ? c.stats().loadCount() : 0L; + } + + @Override + protected void bindImplementationSpecificMetrics(MeterRegistry registry) { + // Intentionally empty - see class javadoc. + } + + private @Nullable Cache current() { + AtomicReference> ref = getCache(); + return ref != null ? ref.get() : null; + } + } } diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java index 24e5227e2..1b25fa811 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/cache/LocalResponseCacheGatewayFilterFactory.java @@ -122,11 +122,34 @@ public List shortcutFieldOrder() { return List.of("timeToLive", "size"); } + /** + * Callback invoked by {@link LocalResponseCacheGatewayFilterFactory} (and + * {@code LocalResponseCacheAutoConfiguration} for the global cache) each time a + * Caffeine cache backing {@code LocalResponseCache} is created or replaced. Allows + * external components - typically a Micrometer binder - to attach observers without + * the filter factory depending on Micrometer directly. + * + *

+ * Implementations must be safe to invoke multiple times with the same {@code + * cacheName} (e.g. on every route refresh) since the gateway may rebuild the cache. + */ @FunctionalInterface public interface CacheMetricsListener { + /** + * Invoked after a Caffeine cache for {@code cacheName} is created (or re-created + * on refresh). + * @param cache the current Caffeine cache instance. + * @param cacheName the cache name used for tagging metrics; for per-route caches + * this is {@code -cache}. + */ void onCacheCreated(com.github.benmanes.caffeine.cache.Cache cache, String cacheName); + /** + * No-op default returned when no metrics binder is wired in (e.g. when Micrometer + * is absent or + * {@code spring.cloud.gateway.server.webflux.metrics.enabled=false}). + */ CacheMetricsListener NOOP = (cache, cacheName) -> { }; From f8139f5247b3cd40027966da01fbc31690b5d828 Mon Sep 17 00:00:00 2001 From: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> Date: Fri, 29 May 2026 21:39:02 +0900 Subject: [PATCH 8/9] Add regression tests for cache metrics across route refresh Two tests: - metricsKeepTrackingCurrentCacheAcrossReplacement exercises the listener directly: bind one cache, swap in a second cache for the same cacheName, populate the new cache, assert cache.size reports the new cache's entries (5.0, not NaN) and only one meter exists. - perRouteMetricsSurviveRouteRefresh drives the full LocalResponseCacheGatewayFilterFactory: call apply twice with the same routeId (the refresh scenario), populate the resulting cache, assert cache.size{cache=my-route-cache} reflects it. Both tests fail against the previous lambda listener and pass against SwappableCacheMetricsListener. Signed-off-by: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> --- ...nseCacheMetricsAutoConfigurationTests.java | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java index 6db7dd96a..18dd7eb88 100644 --- a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java @@ -18,7 +18,9 @@ import java.time.Duration; +import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; +import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; @@ -167,6 +169,88 @@ void perRouteCacheMetricsRegisteredViaFilterFactory() { assertThat(registry.find("cache.size").tag("cache", "my-route-cache").gauge()).isNotNull(); } + @Test + void metricsKeepTrackingCurrentCacheAcrossReplacement() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LocalResponseCacheMetricsAutoConfiguration.class)) + .withBean(MeterRegistry.class, () -> registry) + .run(context -> { + CacheMetricsListener listener = context.getBean(CacheMetricsListener.class); + + Cache firstCache = Caffeine.newBuilder().recordStats().build(); + listener.onCacheCreated(firstCache, "my-route-cache"); + firstCache.put("a", "1"); + firstCache.put("b", "2"); + + Cache secondCache = Caffeine.newBuilder().recordStats().build(); + listener.onCacheCreated(secondCache, "my-route-cache"); + for (int i = 0; i < 5; i++) { + secondCache.put("key-" + i, "value-" + i); + } + + Gauge sizeGauge = registry.find("cache.size").tag("cache", "my-route-cache").gauge(); + assertThat(sizeGauge).isNotNull(); + assertThat(sizeGauge.value()).isEqualTo(5.0); + + long meterCount = registry.getMeters() + .stream() + .filter(m -> "cache.size".equals(m.getId().getName())) + .filter(m -> "my-route-cache".equals(m.getId().getTag("cache"))) + .count(); + assertThat(meterCount).isEqualTo(1); + }); + } + + @Test + void perRouteMetricsSurviveRouteRefresh() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(LocalResponseCacheMetricsAutoConfiguration.class)) + .withBean(MeterRegistry.class, () -> registry) + .run(context -> { + CacheMetricsListener listener = context.getBean(CacheMetricsListener.class); + Duration ttl = Duration.ofMinutes(5); + ResponseCacheManagerFactory cacheManagerFactory = new ResponseCacheManagerFactory( + new CacheKeyGenerator()); + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + LocalResponseCacheGatewayFilterFactory factory = new LocalResponseCacheGatewayFilterFactory( + cacheManagerFactory, ttl, null, new LocalResponseCacheProperties.RequestOptions(), cacheManager, + listener); + + LocalResponseCacheGatewayFilterFactory.RouteCacheConfiguration routeConfig = new LocalResponseCacheGatewayFilterFactory.RouteCacheConfiguration(); + routeConfig.setRouteId("my-route"); + routeConfig.setTimeToLive(ttl); + + factory.apply(routeConfig); + factory.apply(routeConfig); + + org.springframework.cache.Cache currentCache = cacheManager.getCache("my-route-cache"); + assertThat(currentCache).isNotNull(); + for (int i = 0; i < 5; i++) { + currentCache.put("key-" + i, "value-" + i); + } + currentCache.get("key-0"); + currentCache.get("missing"); + + Gauge sizeGauge = registry.find("cache.size").tag("cache", "my-route-cache").gauge(); + assertThat(sizeGauge).isNotNull(); + assertThat(sizeGauge.value()).isEqualTo(5.0); + assertThat(registry.find("cache.gets") + .tag("cache", "my-route-cache") + .tag("result", "hit") + .functionCounter() + .count()).isEqualTo(1.0); + assertThat(registry.find("cache.gets") + .tag("cache", "my-route-cache") + .tag("result", "miss") + .functionCounter() + .count()).isEqualTo(1.0); + }); + } + @Configuration(proxyBeanMethods = false) static class MeterRegistryConfig { From ef13b8629a9ae8d3cbdcf1bf2bc7b6d619ca63a2 Mon Sep 17 00:00:00 2001 From: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> Date: Fri, 29 May 2026 21:39:02 +0900 Subject: [PATCH 9/9] Document LocalResponseCache cache metrics Note the standard cache.* meters exposed for per-route and global caches, the cache tag convention, the metrics.enabled property, and that the gauges remain valid across route refreshes. Signed-off-by: Jooyoung Jung <143606756+LivingLikeKrillin@users.noreply.github.com> --- .../local-cache-response-filter.adoc | 20 +++++++++++++++++++ .../global-filters.adoc | 9 +++++++++ 2 files changed, 29 insertions(+) diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/local-cache-response-filter.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/local-cache-response-filter.adoc index c986aa622..0c9bb664c 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/local-cache-response-filter.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/gatewayfilter-factories/local-cache-response-filter.adoc @@ -54,4 +54,24 @@ NOTE: To enable this feature, add `com.github.ben-manes.caffeine:caffeine` and ` WARNING: If your project creates custom `CacheManager` beans, it will either need to be marked with `@Primary` or injected using `@Qualifier`. +[[local-cache-response-filter-metrics]] +== Metrics + +To expose cache metrics, add `spring-boot-starter-actuator` (and a Micrometer registry implementation such as `micrometer-registry-prometheus`) as project dependencies. +When `spring.cloud.gateway.server.webflux.metrics.enabled` is `true` (the default), the gateway emits the standard Micrometer cache meters for every route-level cache created by the `LocalResponseCache` filter: + +* `cache.size` +* `cache.gets` (with `result` tag of `hit` or `miss`) +* `cache.puts` +* `cache.evictions` + +Each meter is tagged with `cache=-cache`, so a route declared with id `my-route` is observable via `cache.size{cache=my-route-cache}`. +The gauges continue to reflect the current cache after a route refresh; the underlying Caffeine instance may be replaced by the gateway, but the meter binding stays valid. + +NOTE: Meters are only registered when a `MeterRegistry` bean is present on the application context. + +NOTE: Set `spring.cloud.gateway.server.webflux.metrics.enabled=false` to opt out. + +NOTE: `cache.puts` is sourced from Caffeine's `loadCount` and is incremented only by `LoadingCache`-style automatic loads. Because `LocalResponseCache` is populated by the response-cache filter without a `CacheLoader`, this meter remains at `0` in normal use; cache activity is best observed via `cache.size`, `cache.gets`, and `cache.evictions`. + diff --git a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/global-filters.adoc b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/global-filters.adoc index 5ee63d466..772517cfe 100644 --- a/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/global-filters.adoc +++ b/docs/modules/ROOT/pages/spring-cloud-gateway-server-webflux/global-filters.adoc @@ -94,6 +94,15 @@ NOTE: To enable this feature, add `com.github.ben-manes.caffeine:caffeine` and ` WARNING: If your project creates custom `CacheManager` beans, it will either need to be marked with `@Primary` or injected using `@Qualifier`. +When `spring-boot-starter-actuator` is on the classpath and `spring.cloud.gateway.server.webflux.metrics.enabled` is `true` (the default), the global cache exposes the following standard Micrometer cache meters, tagged with `cache=response-cache`: + +* `cache.size` +* `cache.gets` (with `result` tag of `hit` or `miss`) +* `cache.puts` +* `cache.evictions` + +See xref:spring-cloud-gateway-server-webflux/gatewayfilter-factories/local-cache-response-filter.adoc#local-cache-response-filter-metrics[the LocalResponseCache filter docs] for the per-route equivalent. + [[forward-routing-filter]] == Forward Routing Filter