diff --git a/README.md b/README.md index 3f15abfe3..d16152fdf 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,21 @@ Service instrumentedService = Tritium.instrument(Service.class, interestingService, environment.metrics()); ``` +## Instrumenting a [Caffeine cache](https://github.com/ben-manes/caffeine/) + +```java +import com.palantir.tritium.metrics.caffeine.CacheStats; + +TaggedMetricRegistry taggedMetricRegistry = ... +Cache cache = Caffeine.newBuilder() + .recordStats(CacheStats.of(taggedMetricRegistry, "unique-cache-name")) + .build(); + +LoadingCache loadingCache = Caffeine.newBuilder() + .recordStats(CacheStats.of(taggedMetricRegistry, "unique-loading-cache-name")) + .build(key::length); +``` + ## Creating a metric registry with reservoirs backed by [HDR Histograms](https://hdrhistogram.github.io/HdrHistogram/). HDR histograms are more useful if the service is long running, so the stats represents the lifetime of the server rather than using default exponential decay which can lead to some mis-interpretations of timings (especially higher percentiles and things like max dropping over time) if the consumer isn't aware of these assumptions. diff --git a/changelog/@unreleased/pr-1897.v2.yml b/changelog/@unreleased/pr-1897.v2.yml new file mode 100644 index 000000000..52f601a0b --- /dev/null +++ b/changelog/@unreleased/pr-1897.v2.yml @@ -0,0 +1,18 @@ +type: improvement +improvement: + description: | + Initial Caffeine CacheStats recorder + + Example usage: + ``` + TaggedMetricRegistry taggedMetricRegistry = ... + Cache cache = Caffeine.newBuilder() + .recordStats(CacheStats.of(taggedMetricRegistry, "unique-cache-name")) + .build(); + + LoadingCache loadingCache = Caffeine.newBuilder() + .recordStats(CacheStats.of(taggedMetricRegistry, "unique-loading-cache-name")) + .build(key::length); + ``` + links: + - https://github.com/palantir/tritium/pull/1897 diff --git a/tritium-caffeine/build.gradle b/tritium-caffeine/build.gradle index 66dcb746d..36ab16622 100644 --- a/tritium-caffeine/build.gradle +++ b/tritium-caffeine/build.gradle @@ -1,4 +1,5 @@ apply plugin: 'com.palantir.external-publish-jar' +apply plugin: 'com.palantir.metric-schema' dependencies { api 'com.github.ben-manes.caffeine:caffeine' @@ -11,6 +12,7 @@ dependencies { implementation 'com.palantir.safe-logging:preconditions' implementation 'com.palantir.safe-logging:safe-logging' implementation 'io.dropwizard.metrics:metrics-core' + implementation 'org.checkerframework:checker-qual' testImplementation 'org.assertj:assertj-core' testImplementation 'org.awaitility:awaitility' diff --git a/tritium-caffeine/src/main/java/com/palantir/tritium/metrics/caffeine/CacheStats.java b/tritium-caffeine/src/main/java/com/palantir/tritium/metrics/caffeine/CacheStats.java new file mode 100644 index 000000000..f7273aaba --- /dev/null +++ b/tritium-caffeine/src/main/java/com/palantir/tritium/metrics/caffeine/CacheStats.java @@ -0,0 +1,132 @@ +/* + * (c) Copyright 2024 Palantir Technologies Inc. All rights reserved. + * + * 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 + * + * http://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 com.palantir.tritium.metrics.caffeine; + +import com.codahale.metrics.Counting; +import com.codahale.metrics.Meter; +import com.codahale.metrics.Timer; +import com.github.benmanes.caffeine.cache.RemovalCause; +import com.github.benmanes.caffeine.cache.stats.StatsCounter; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Maps; +import com.palantir.logsafe.Safe; +import com.palantir.tritium.metrics.caffeine.CacheMetrics.Load_Result; +import com.palantir.tritium.metrics.registry.TaggedMetricRegistry; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.LongAdder; +import java.util.function.Supplier; +import org.checkerframework.checker.index.qual.NonNegative; + +public final class CacheStats implements StatsCounter, Supplier { + private final String name; + private final Meter hitMeter; + private final Meter missMeter; + private final Timer loadSuccessTimer; + private final Timer loadFailureTimer; + private final Meter evictionsTotalMeter; + private final ImmutableMap evictionMeters; + private final LongAdder totalLoadNanos = new LongAdder(); + + /** + * Creates a {@link CacheStats} instance that registers metrics for Caffeine cache statistics. + *

+ * Example usage for a {@link com.github.benmanes.caffeine.cache.Cache} or + * {@link com.github.benmanes.caffeine.cache.LoadingCache}: + *

+     *     LoadingCache<Integer, String> cache = Caffeine.newBuilder()
+     *             .recordStats(CacheStats.of(taggedMetricRegistry, "your-cache-name"))
+     *             .build(key -> computeSomethingExpensive(key));
+     * 
+ * @param taggedMetricRegistry tagged metric registry to add cache metrics + * @param name cache name + * @return Caffeine stats instance to register via + * {@link com.github.benmanes.caffeine.cache.Caffeine#recordStats(Supplier)}. + */ + public static CacheStats of(TaggedMetricRegistry taggedMetricRegistry, @Safe String name) { + return new CacheStats(CacheMetrics.of(taggedMetricRegistry), name); + } + + private CacheStats(CacheMetrics metrics, @Safe String name) { + this.name = name; + this.hitMeter = metrics.hit(name); + this.missMeter = metrics.miss(name); + this.loadSuccessTimer = + metrics.load().cache(name).result(Load_Result.SUCCESS).build(); + this.loadFailureTimer = + metrics.load().cache(name).result(Load_Result.FAILURE).build(); + this.evictionsTotalMeter = metrics.eviction(name); + this.evictionMeters = Arrays.stream(RemovalCause.values()) + .collect(Maps.toImmutableEnumMap(cause -> cause, cause -> metrics.evictions() + .cache(name) + .cause(cause.toString()) + .build())); + } + + @Override + public StatsCounter get() { + return this; + } + + @Override + public void recordHits(@NonNegative int count) { + hitMeter.mark(count); + } + + @Override + public void recordMisses(@NonNegative int count) { + missMeter.mark(count); + } + + @Override + public void recordLoadSuccess(@NonNegative long loadTime) { + loadSuccessTimer.update(loadTime, TimeUnit.NANOSECONDS); + totalLoadNanos.add(loadTime); + } + + @Override + public void recordLoadFailure(@NonNegative long loadTime) { + loadFailureTimer.update(loadTime, TimeUnit.NANOSECONDS); + totalLoadNanos.add(loadTime); + } + + @Override + public void recordEviction(@NonNegative int weight, RemovalCause cause) { + Meter counter = evictionMeters.get(cause); + if (counter != null) { + counter.mark(weight); + } + evictionsTotalMeter.mark(weight); + } + + @Override + public com.github.benmanes.caffeine.cache.stats.CacheStats snapshot() { + return com.github.benmanes.caffeine.cache.stats.CacheStats.of( + hitMeter.getCount(), + missMeter.getCount(), + loadSuccessTimer.getCount(), + loadFailureTimer.getCount(), + totalLoadNanos.sum(), + evictionsTotalMeter.getCount(), + evictionMeters.values().stream().mapToLong(Counting::getCount).sum()); + } + + @Override + public String toString() { + return name + ": " + snapshot(); + } +} diff --git a/tritium-caffeine/src/main/java/com/palantir/tritium/metrics/caffeine/CaffeineCacheStats.java b/tritium-caffeine/src/main/java/com/palantir/tritium/metrics/caffeine/CaffeineCacheStats.java index 937898dea..6cdf205fb 100644 --- a/tritium-caffeine/src/main/java/com/palantir/tritium/metrics/caffeine/CaffeineCacheStats.java +++ b/tritium-caffeine/src/main/java/com/palantir/tritium/metrics/caffeine/CaffeineCacheStats.java @@ -33,6 +33,7 @@ import com.palantir.tritium.metrics.registry.TaggedMetricRegistry; import java.util.concurrent.TimeUnit; import java.util.function.Function; +import java.util.function.Supplier; public final class CaffeineCacheStats { @@ -43,7 +44,7 @@ private CaffeineCacheStats() {} /** * Register specified cache with the given metric registry. - * + *

* Callers should ensure that they have {@link Caffeine#recordStats() enabled stats recording} * {@code Caffeine.newBuilder().recordStats()} otherwise there are no cache metrics to register. * @@ -69,14 +70,17 @@ public static void registerCache(MetricRegistry registry, Cache cache, Str /** * Register specified cache with the given metric registry. - * + *

* Callers should ensure that they have {@link Caffeine#recordStats() enabled stats recording} * {@code Caffeine.newBuilder().recordStats()} otherwise there are no cache metrics to register. * * @param registry metric registry * @param cache cache to instrument * @param name cache name + *

+ * Soon to be deprecated, prefer {@link Caffeine#recordStats(Supplier)} and {@link CacheStats#of(TaggedMetricRegistry, String)} */ + // Soon to be @Deprecated public static void registerCache(TaggedMetricRegistry registry, Cache cache, @Safe String name) { checkNotNull(registry, "registry"); checkNotNull(cache, "cache"); diff --git a/tritium-caffeine/src/main/metrics/cache-metrics.yml b/tritium-caffeine/src/main/metrics/cache-metrics.yml new file mode 100644 index 000000000..0dde1da6a --- /dev/null +++ b/tritium-caffeine/src/main/metrics/cache-metrics.yml @@ -0,0 +1,37 @@ +options: + javaPackage: com.palantir.tritium.metrics.caffeine + javaVisibility: packagePrivate +namespaces: + cache: + docs: Cache statistic metrics + metrics: + hit: + type: meter + tags: [cache] + docs: Count of cache hits + miss: + type: meter + tags: [cache] + docs: Count of cache misses + load: + type: timer + tags: + - name: cache + - name: result + values: [success, failure] + docs: Count of successful cache loads + evictions: + type: meter + tags: [cache, cause] + docs: Count of evicted entries by cause + eviction: + type: meter + tags: [cache] + docs: Total count of evicted entries + stats.disabled: + type: meter + tags: [cache] + docs: | + Registered cache does not have stats recording enabled, stats will always be zero. + To enable cache metrics, stats recording must be enabled when constructing the cache: + Caffeine.newBuilder().recordStats() diff --git a/tritium-caffeine/src/test/java/com/palantir/tritium/metrics/caffeine/CaffeineCacheStatsTest.java b/tritium-caffeine/src/test/java/com/palantir/tritium/metrics/caffeine/CaffeineCacheStatsTest.java index 54178c48b..482b0275c 100644 --- a/tritium-caffeine/src/test/java/com/palantir/tritium/metrics/caffeine/CaffeineCacheStatsTest.java +++ b/tritium-caffeine/src/test/java/com/palantir/tritium/metrics/caffeine/CaffeineCacheStatsTest.java @@ -17,23 +17,36 @@ package com.palantir.tritium.metrics.caffeine; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.InstanceOfAssertFactories.collection; import static org.assertj.core.api.InstanceOfAssertFactories.type; import static org.awaitility.Awaitility.await; import com.codahale.metrics.ConsoleReporter; import com.codahale.metrics.Counter; +import com.codahale.metrics.Counting; import com.codahale.metrics.Gauge; +import com.codahale.metrics.Meter; import com.codahale.metrics.Metric; import com.codahale.metrics.MetricRegistry; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; +import com.google.common.collect.ImmutableSetMultimap; +import com.google.common.collect.Multimaps; import com.palantir.tritium.metrics.registry.DefaultTaggedMetricRegistry; import com.palantir.tritium.metrics.registry.MetricName; import com.palantir.tritium.metrics.registry.TaggedMetricRegistry; import java.time.Duration; import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; import java.util.function.Function; +import org.assertj.core.api.AbstractLongAssert; import org.assertj.core.api.AbstractObjectAssert; +import org.assertj.core.api.InstanceOfAssertFactories; +import org.assertj.core.api.ObjectAssert; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; @@ -202,21 +215,165 @@ void registerCacheWithoutRecordingStatsTagged() { .isEqualTo(1L); } - static AbstractObjectAssert assertGauge(TaggedMetricRegistry taggedMetricRegistry, String name) { - Gauge metric = getMetric(taggedMetricRegistry, Gauge.class, name); + @Test + void registerTaggedMetrics() { + Cache cache = Caffeine.newBuilder() + .recordStats(CacheStats.of(taggedMetricRegistry, "test")) + .maximumSize(2) + .build(); + assertThat(taggedMetricRegistry.getMetrics().keySet()) + .extracting(MetricName::safeName) + .containsExactlyInAnyOrder( + "cache.hit", + "cache.miss", + "cache.eviction", + "cache.evictions", // RemovalCause.EXPLICIT + "cache.evictions", // RemovalCause.REPLACED + "cache.evictions", // RemovalCause.COLLECTED + "cache.evictions", // RemovalCause.EXPIRED + "cache.evictions", // RemovalCause.SIZE + "cache.load", // success + "cache.load" // failure + ); + + CacheMetrics cacheMetrics = CacheMetrics.of(taggedMetricRegistry); + assertThat(cacheMetrics.evictions().cache("test").cause("SIZE").build().getCount()) + .asInstanceOf(InstanceOfAssertFactories.LONG) + .isZero(); + assertMeter(taggedMetricRegistry, "cache.hit") + .isEqualTo(cacheMetrics.hit("test").getCount()) + .isZero(); + assertMeter(taggedMetricRegistry, "cache.miss") + .isEqualTo(cacheMetrics.miss("test").getCount()) + .isZero(); + + assertThat(cache.get(0, mapping)).isEqualTo("0"); + assertThat(cache.get(1, mapping)).isEqualTo("1"); + assertThat(cache.get(2, mapping)).isEqualTo("2"); + assertThat(cache.get(1, mapping)).isEqualTo("1"); + + assertMeter(taggedMetricRegistry, "cache.hit") + .isEqualTo(cacheMetrics.hit("test").getCount()) + .isOne(); + assertMeter(taggedMetricRegistry, "cache.miss") + .isEqualTo(cacheMetrics.miss("test").getCount()) + .isEqualTo(3); + + cache.cleanUp(); // force eviction processing + assertThat(cacheMetrics.evictions().cache("test").cause("SIZE").build().getCount()) + .asInstanceOf(InstanceOfAssertFactories.LONG) + .isOne(); + + assertThat(taggedMetricRegistry.getMetrics()) + .extractingByKey(MetricName.builder() + .safeName("cache.stats.disabled") + .putSafeTags("cache", "test") + .build()) + .isNull(); + } + + @Test + void registerLoadingTaggedMetrics() { + LoadingCache cache = Caffeine.newBuilder() + .recordStats(CacheStats.of(taggedMetricRegistry, "test")) + .maximumSize(2) + .build(mapping::apply); + assertThat(taggedMetricRegistry.getMetrics().keySet()) + .extracting(MetricName::safeName) + .containsExactlyInAnyOrder( + "cache.hit", + "cache.miss", + "cache.eviction", + "cache.evictions", // RemovalCause.EXPLICIT + "cache.evictions", // RemovalCause.REPLACED + "cache.evictions", // RemovalCause.COLLECTED + "cache.evictions", // RemovalCause.EXPIRED + "cache.evictions", // RemovalCause.SIZE + "cache.load", // success + "cache.load" // failure + ); + + CacheMetrics cacheMetrics = CacheMetrics.of(taggedMetricRegistry); + assertThat(cacheMetrics.evictions().cache("test").cause("SIZE").build().getCount()) + .asInstanceOf(InstanceOfAssertFactories.LONG) + .isZero(); + assertMeter(taggedMetricRegistry, "cache.hit") + .isEqualTo(cacheMetrics.hit("test").getCount()) + .isZero(); + assertMeter(taggedMetricRegistry, "cache.miss") + .isEqualTo(cacheMetrics.miss("test").getCount()) + .isZero(); + + assertThat(cache.get(0)).isEqualTo("0"); + assertThat(cache.get(1)).isEqualTo("1"); + assertThat(cache.get(2)).isEqualTo("2"); + assertThat(cache.get(1)).isEqualTo("1"); + + assertMeter(taggedMetricRegistry, "cache.hit") + .isEqualTo(cacheMetrics.hit("test").getCount()) + .isOne(); + assertMeter(taggedMetricRegistry, "cache.miss") + .isEqualTo(cacheMetrics.miss("test").getCount()) + .isEqualTo(3); + + cache.cleanUp(); // force eviction processing + assertThat(cacheMetrics.evictions().cache("test").cause("SIZE").build().getCount()) + .asInstanceOf(InstanceOfAssertFactories.LONG) + .isOne(); + + assertThat(taggedMetricRegistry.getMetrics()) + .extractingByKey(MetricName.builder() + .safeName("cache.stats.disabled") + .putSafeTags("cache", "test") + .build()) + .isNull(); + } + + static AbstractObjectAssert assertGauge(TaggedMetricRegistry taggedMetricRegistry, String name) { + return assertMetric(taggedMetricRegistry, Gauge.class, name).extracting(Gauge::getValue); + } + + static AbstractLongAssert assertMeter(TaggedMetricRegistry taggedMetricRegistry, String name) { + return assertMetric(taggedMetricRegistry, Meter.class, name) + .extracting(Counting::getCount) + .asInstanceOf(InstanceOfAssertFactories.LONG); + } + + static ObjectAssert assertMetric( + TaggedMetricRegistry taggedMetricRegistry, Class clazz, String name) { + T metric = getMetric(taggedMetricRegistry, clazz, name); return assertThat(metric) .as("metric '%s': '%s'", name, metric) .isNotNull() - .extracting(Gauge::getValue); + .asInstanceOf(type(clazz)); } private static T getMetric(TaggedMetricRegistry metrics, Class clazz, String name) { - return clazz.cast(metrics.getMetrics().entrySet().stream() + Optional> metric = metrics.getMetrics().entrySet().stream() .filter(e -> name.equals(e.getKey().safeName())) .filter(e -> clazz.isInstance(e.getValue())) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException( - "No such metric '" + name + "' of type " + clazz.getCanonicalName())) - .getValue()); + .findFirst(); + if (metric.isEmpty()) { + Map> metricNameToType = Multimaps.asMap(metrics.getMetrics().entrySet().stream() + .filter(e -> Objects.nonNull(e.getKey())) + .filter(e -> Objects.nonNull(e.getValue())) + .collect(ImmutableSetMultimap.toImmutableSetMultimap( + e -> e.getKey().safeName(), e -> Optional.ofNullable(e.getValue()) + .map(x -> x.getClass().getCanonicalName()) + .orElse("")))); + + assertThat(metricNameToType) + .containsKey(name) + .extractingByKey(name) + .asInstanceOf(collection(String.class)) + .contains(clazz.getCanonicalName()); + + assertThat(metric) + .as( + "Metric named '%s' of type '%s' should exist but was not found in [%s]", + name, clazz.getCanonicalName(), metricNameToType.keySet()) + .isPresent(); + } + return clazz.cast(metric.orElseThrow().getValue()); } } diff --git a/tritium-metrics/src/main/java/com/palantir/tritium/metrics/MetricRegistries.java b/tritium-metrics/src/main/java/com/palantir/tritium/metrics/MetricRegistries.java index b6c2f8d28..53d309730 100644 --- a/tritium-metrics/src/main/java/com/palantir/tritium/metrics/MetricRegistries.java +++ b/tritium-metrics/src/main/java/com/palantir/tritium/metrics/MetricRegistries.java @@ -270,7 +270,10 @@ static void registerCache(MetricRegistry registry, Cache cache, @Safe Stri * @param cache cache to instrument * @param name cache name * @throws IllegalArgumentException if name is blank + * @deprecated Do not use Guava caches, they are outperformed by and harder to use than Caffeine caches. + * Prefer {@link Caffeine#recordStats(Supplier)} and {@link CacheStats#of(TaggedMetricRegistry, String)}. */ + @Deprecated // BanGuavaCaches @SuppressWarnings("BanGuavaCaches") // this implementation is explicitly for Guava caches public static void registerCache(TaggedMetricRegistry registry, Cache cache, @Safe String name) { checkNotNull(registry, "metric registry"); diff --git a/tritium-metrics/src/test/java/com/palantir/tritium/metrics/MetricRegistriesTest.java b/tritium-metrics/src/test/java/com/palantir/tritium/metrics/MetricRegistriesTest.java index fce417a10..eb840265b 100644 --- a/tritium-metrics/src/test/java/com/palantir/tritium/metrics/MetricRegistriesTest.java +++ b/tritium-metrics/src/test/java/com/palantir/tritium/metrics/MetricRegistriesTest.java @@ -289,6 +289,7 @@ void testNoStats() { } @Test + @SuppressWarnings("deprecation") // testing deprecated cache registration void registerCacheTaggedMetrics() throws ExecutionException { MetricRegistries.registerCache(taggedMetricRegistry, cache, "test"); assertThat(taggedMetricRegistry.getMetrics().keySet())