From 9d8df3b4d0818be4b6dcc4183bc2917b25514179 Mon Sep 17 00:00:00 2001 From: Artem Bilan Date: Tue, 13 Aug 2024 12:34:43 -0400 Subject: [PATCH] GH-458: Introduce `MetricsRetryListener` Fixes: #458 * Fix code formatting violations * * Make `retryContextToSample` as an `IdentityHashMap` and use `RetryContext` as a key * Change `setCustomTags()` to the `@Nullable Iterable` argument * Use `exception = none` tag for successful executions to avoid time-series conflicts --- README.md | 8 ++ pom.xml | 7 + .../retry/support/MetricsRetryListener.java | 128 ++++++++++++++++++ .../retry/support/RetryMetricsTests.java | 119 ++++++++++++++++ 4 files changed, 262 insertions(+) create mode 100644 src/main/java/org/springframework/retry/support/MetricsRetryListener.java create mode 100644 src/test/java/org/springframework/retry/support/RetryMetricsTests.java diff --git a/README.md b/README.md index b1beec00..6cd78528 100644 --- a/README.md +++ b/README.md @@ -783,6 +783,14 @@ The preceding example uses a default `RetryTemplate` inside the interceptor. To policies or listeners, you need only inject an instance of `RetryTemplate` into the interceptor. +## Micrometer Support + +Starting with version 2.0.8, the `MetricsRetryListener` implementation is provided to be injected into a `RetryTemplate` or referenced via `@Retryable(listeners)` attribute. +This `MetricsRetryListener` is based on the [Micrometer](https://docs.micrometer.io/micrometer/reference/index.html) `MeterRegistry` and exposes a `spring.retry` timer from `open()` till `close()` listener callbacks. +Such a timer, essentially, covers the whole retry operation and, in addition to the `name` tag based on `RetryCallback.getLabel()` value, it adds tags like `retry.count` (`0` if no any retries entered - first call is successful) and `exception` (if all the retry attempts have been exhausted, so the last exception is thrown back to the caller). +The `MetricsRetryListener` can be customized with static tags, or via `Function>`. +See `MetricsRetryListener` Javadocs for more information. + ## Contributing Spring Retry is released under the non-restrictive Apache 2.0 license diff --git a/pom.xml b/pom.xml index 19887352..6ac8dba4 100644 --- a/pom.xml +++ b/pom.xml @@ -38,6 +38,7 @@ 2.23.1 5.11.0 6.0.22 + 1.13.2 @@ -66,6 +67,12 @@ spring-core true + + io.micrometer + micrometer-core + ${micrometer.version} + true + org.springframework diff --git a/src/main/java/org/springframework/retry/support/MetricsRetryListener.java b/src/main/java/org/springframework/retry/support/MetricsRetryListener.java new file mode 100644 index 00000000..0ff1a367 --- /dev/null +++ b/src/main/java/org/springframework/retry/support/MetricsRetryListener.java @@ -0,0 +1,128 @@ +/* + * Copyright 2024 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.retry.support; + +import java.util.IdentityHashMap; +import java.util.Map; +import java.util.function.Function; + +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.Timer; + +import org.springframework.lang.Nullable; +import org.springframework.retry.RetryCallback; +import org.springframework.retry.RetryContext; +import org.springframework.retry.RetryListener; +import org.springframework.util.Assert; + +/** + * The {@link RetryListener} implementation for Micrometer {@link Timer}s around retry + * operations. + *

+ * The {@link Timer#start} is called from the {@link #open(RetryContext, RetryCallback)} + * and stopped in the {@link #close(RetryContext, RetryCallback, Throwable)}. This + * {@link Timer.Sample} is associated with the provided {@link RetryContext} to make this + * {@link MetricsRetryListener} instance reusable for many retry operation. + *

+ * The registered {@value #TIMER_NAME} {@link Timer} has these tags by default: + *

    + *
  • {@code name} - {@link RetryCallback#getLabel()}
  • + *
  • {@code retry.count} - the number of attempts - 1; essentially the successful first + * call means no counts
  • + *
  • {@code exception} - the thrown back to the caller (after all the retry attempts) + * exception class name
  • + *
+ *

+ * The {@link #setCustomTags(Iterable)} and {@link #setCustomTagsProvider(Function)} can + * be used to further customize tags on the timers. + * + * @author Artem Bilan + * @since 2.0.8 + */ +public class MetricsRetryListener implements RetryListener { + + public static final String TIMER_NAME = "spring.retry"; + + private final MeterRegistry meterRegistry; + + private final Map retryContextToSample = new IdentityHashMap<>(); + + private final Meter.MeterProvider retryMeterProvider; + + private Tags customTags = Tags.empty(); + + private Function> customTagsProvider = retryContext -> Tags.empty(); + + /** + * Construct an instance based on the provided {@link MeterRegistry}. + * @param meterRegistry the {@link MeterRegistry} to use for timers. + */ + public MetricsRetryListener(MeterRegistry meterRegistry) { + Assert.notNull(meterRegistry, "'meterRegistry' must not be null"); + this.meterRegistry = meterRegistry; + this.retryMeterProvider = Timer.builder(TIMER_NAME) + .description("Metrics for Spring RetryTemplate") + .withRegistry(this.meterRegistry); + } + + /** + * Supply tags which are going to be used for all the timers managed by this listener. + * @param customTags the list of additional tags for all the timers. + */ + public void setCustomTags(@Nullable Iterable customTags) { + this.customTags = this.customTags.and(customTags); + } + + /** + * Supply a {@link Function} to build additional tags for all the timers based on the + * {@link RetryContext}. + * @param customTagsProvider the {@link Function} for additional tags with a + * {@link RetryContext} scope. + */ + public void setCustomTagsProvider(Function> customTagsProvider) { + Assert.notNull(customTagsProvider, "'customTagsProvider' must not be null"); + this.customTagsProvider = customTagsProvider; + } + + @Override + public boolean open(RetryContext context, RetryCallback callback) { + this.retryContextToSample.put(context, Timer.start(this.meterRegistry)); + return true; + } + + @Override + public void close(RetryContext context, RetryCallback callback, + @Nullable Throwable throwable) { + + Timer.Sample sample = this.retryContextToSample.remove(context); + + Assert.state(sample != null, + () -> String.format("No 'Timer.Sample' registered for '%s'. Was the 'open()' called?", context)); + + Tags retryTags = Tags.of("name", callback.getLabel()) + .and("retry.count", "" + context.getRetryCount()) + .and(this.customTags) + .and(this.customTagsProvider.apply(context)) + .and("exception", throwable != null ? throwable.getClass().getSimpleName() : "none"); + + sample.stop(this.retryMeterProvider.withTags(retryTags)); + } + +} diff --git a/src/test/java/org/springframework/retry/support/RetryMetricsTests.java b/src/test/java/org/springframework/retry/support/RetryMetricsTests.java new file mode 100644 index 00000000..46985b4f --- /dev/null +++ b/src/test/java/org/springframework/retry/support/RetryMetricsTests.java @@ -0,0 +1,119 @@ +/* + * Copyright 2024 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.retry.support; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.RetryException; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.retry.annotation.Retryable; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * @author Artem Bilan + * @since 2.0.8 + */ +@SpringJUnitConfig +public class RetryMetricsTests { + + @Autowired + MeterRegistry meterRegistry; + + @Autowired + Service service; + + @Test + void metricsAreCollectedForRetryable() { + assertThatNoException().isThrownBy(this.service::service1); + assertThatNoException().isThrownBy(this.service::service1); + assertThatNoException().isThrownBy(this.service::service2); + assertThatExceptionOfType(RetryException.class).isThrownBy(this.service::service3); + + assertThat(this.meterRegistry.get(MetricsRetryListener.TIMER_NAME) + .tags(Tags.of("name", "org.springframework.retry.support.RetryMetricsTests$Service.service1", "retry.count", + "0", "exception", "none")) + .timer() + .count()).isEqualTo(2); + + assertThat(this.meterRegistry.get(MetricsRetryListener.TIMER_NAME) + .tags(Tags.of("name", "org.springframework.retry.support.RetryMetricsTests$Service.service2", "retry.count", + "2", "exception", "none")) + .timer() + .count()).isEqualTo(1); + + assertThat(this.meterRegistry.get(MetricsRetryListener.TIMER_NAME) + .tags(Tags.of("name", "org.springframework.retry.support.RetryMetricsTests$Service.service3", "retry.count", + "3", "exception", "RetryException")) + .timer() + .count()).isEqualTo(1); + } + + @Configuration(proxyBeanMethods = false) + @EnableRetry + public static class TestConfiguration { + + @Bean + MeterRegistry meterRegistry() { + return new SimpleMeterRegistry(); + } + + @Bean + MetricsRetryListener metricsRetryListener(MeterRegistry meterRegistry) { + return new MetricsRetryListener(meterRegistry); + } + + @Bean + Service service() { + return new Service(); + } + + } + + protected static class Service { + + private int count = 0; + + @Retryable + public void service1() { + + } + + @Retryable + public void service2() { + if (count++ < 2) { + throw new RuntimeException("Planned"); + } + } + + @Retryable + public void service3() { + throw new RetryException("Planned"); + } + + } + +}