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 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..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,9 +18,8 @@ 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; import org.springframework.boot.autoconfigure.condition.AllNestedConditions; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -28,10 +27,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; @@ -50,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"; @@ -60,10 +59,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 +78,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(), 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 new file mode 100644 index 000000000..0ef21237e --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfiguration.java @@ -0,0 +1,155 @@ +/* + * 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.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.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; +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; +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, + 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 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 95fa4e8d7..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 @@ -63,20 +63,31 @@ 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, + 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 +97,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 +122,39 @@ 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) -> { + }; + + } + @Validated public static class RouteCacheConfiguration implements HasRouteId { 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..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,7 +48,8 @@ public static CaffeineCacheManager createGatewayCacheManager(LocalResponseCacheP @SuppressWarnings({ "unchecked", "rawtypes" }) public static Caffeine createCaffeine(LocalResponseCacheProperties cacheProperties) { - Caffeine caffeine = Caffeine.newBuilder(); + // Record stats unconditionally; LongAdder overhead is negligible. + Caffeine caffeine = Caffeine.newBuilder().recordStats(); LOGGER.info("Initializing Caffeine"); Duration ttlSeconds = cacheProperties.getTimeToLive(); caffeine.expireAfterWrite(ttlSeconds); 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..18dd7eb88 --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/LocalResponseCacheMetricsAutoConfigurationTests.java @@ -0,0 +1,264 @@ +/* + * 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.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; + +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; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link LocalResponseCacheMetricsAutoConfiguration}. + * + * @author LivingLikeKrillin + */ +public 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 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() + .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(); + }); + } + + @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(); + }); + } + + @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(); + } + + @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 { + + @Bean + MeterRegistry meterRegistry() { + return new SimpleMeterRegistry(); + } + + } + +}