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<Tag>` argument * Use `exception = none` tag for successful executions to avoid time-series conflicts
This commit is contained in:
@@ -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<RetryContext, Iterable<Tag>>`.
|
||||
See `MetricsRetryListener` Javadocs for more information.
|
||||
|
||||
## Contributing
|
||||
|
||||
Spring Retry is released under the non-restrictive Apache 2.0 license
|
||||
|
||||
7
pom.xml
7
pom.xml
@@ -38,6 +38,7 @@
|
||||
<log4j.version>2.23.1</log4j.version>
|
||||
<mockito.version>5.11.0</mockito.version>
|
||||
<spring.framework.version>6.0.22</spring.framework.version>
|
||||
<micrometer.version>1.13.2</micrometer.version>
|
||||
</properties>
|
||||
|
||||
<scm>
|
||||
@@ -66,6 +67,12 @@
|
||||
<artifactId>spring-core</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.micrometer</groupId>
|
||||
<artifactId>micrometer-core</artifactId>
|
||||
<version>${micrometer.version}</version>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
|
||||
@@ -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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* The registered {@value #TIMER_NAME} {@link Timer} has these tags by default:
|
||||
* <ul>
|
||||
* <li>{@code name} - {@link RetryCallback#getLabel()}</li>
|
||||
* <li>{@code retry.count} - the number of attempts - 1; essentially the successful first
|
||||
* call means no counts</li>
|
||||
* <li>{@code exception} - the thrown back to the caller (after all the retry attempts)
|
||||
* exception class name</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 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<RetryContext, Timer.Sample> retryContextToSample = new IdentityHashMap<>();
|
||||
|
||||
private final Meter.MeterProvider<Timer> retryMeterProvider;
|
||||
|
||||
private Tags customTags = Tags.empty();
|
||||
|
||||
private Function<RetryContext, Iterable<Tag>> 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<Tag> 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<RetryContext, Iterable<Tag>> customTagsProvider) {
|
||||
Assert.notNull(customTagsProvider, "'customTagsProvider' must not be null");
|
||||
this.customTagsProvider = customTagsProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
|
||||
this.retryContextToSample.put(context, Timer.start(this.meterRegistry));
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> 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));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user