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
Original file line number Diff line number Diff line change
Expand Up @@ -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=<routeId>-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`.


Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,21 @@

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;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
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;
Expand All @@ -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";
Expand All @@ -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<CacheMetricsListener> 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)
Expand All @@ -74,9 +78,11 @@ public CacheManager gatewayCacheManager(LocalResponseCacheProperties cacheProper

@Bean
public LocalResponseCacheGatewayFilterFactory localResponseCacheGatewayFilterFactory(
ResponseCacheManagerFactory responseCacheManagerFactory, LocalResponseCacheProperties properties) {
ResponseCacheManagerFactory responseCacheManagerFactory, LocalResponseCacheProperties properties,
ObjectProvider<CacheMetricsListener> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* {@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<String, AtomicReference<Cache<?, ?>>> refsByCacheName = new ConcurrentHashMap<>();

SwappableCacheMetricsListener(MeterRegistry registry) {
this.registry = registry;
}

@Override
public void onCacheCreated(Cache<?, ?> cache, String cacheName) {
refsByCacheName.computeIfAbsent(cacheName, name -> {
AtomicReference<Cache<?, ?>> 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<AtomicReference<Cache<?, ?>>> {

SwappableCaffeineCacheMetrics(AtomicReference<Cache<?, ?>> ref, String cacheName, Iterable<Tag> 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<Cache<?, ?>> ref = getCache();
return ref != null ? ref.get() : null;
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -109,6 +122,39 @@ public List<String> 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.
*
* <p>
* 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 <routeId>-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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading