Skip to content

Commit 9d8df3b

Browse files
authored
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
1 parent 8e5cafe commit 9d8df3b

File tree

4 files changed

+262
-0
lines changed

4 files changed

+262
-0
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,14 @@ The preceding example uses a default `RetryTemplate` inside the interceptor. To
783783
policies or listeners, you need only inject an instance of `RetryTemplate` into the
784784
interceptor.
785785

786+
## Micrometer Support
787+
788+
Starting with version 2.0.8, the `MetricsRetryListener` implementation is provided to be injected into a `RetryTemplate` or referenced via `@Retryable(listeners)` attribute.
789+
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.
790+
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).
791+
The `MetricsRetryListener` can be customized with static tags, or via `Function<RetryContext, Iterable<Tag>>`.
792+
See `MetricsRetryListener` Javadocs for more information.
793+
786794
## Contributing
787795

788796
Spring Retry is released under the non-restrictive Apache 2.0 license

pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
<log4j.version>2.23.1</log4j.version>
3939
<mockito.version>5.11.0</mockito.version>
4040
<spring.framework.version>6.0.22</spring.framework.version>
41+
<micrometer.version>1.13.2</micrometer.version>
4142
</properties>
4243

4344
<scm>
@@ -66,6 +67,12 @@
6667
<artifactId>spring-core</artifactId>
6768
<optional>true</optional>
6869
</dependency>
70+
<dependency>
71+
<groupId>io.micrometer</groupId>
72+
<artifactId>micrometer-core</artifactId>
73+
<version>${micrometer.version}</version>
74+
<optional>true</optional>
75+
</dependency>
6976

7077
<dependency>
7178
<groupId>org.springframework</groupId>
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright 2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.retry.support;
18+
19+
import java.util.IdentityHashMap;
20+
import java.util.Map;
21+
import java.util.function.Function;
22+
23+
import io.micrometer.core.instrument.Meter;
24+
import io.micrometer.core.instrument.MeterRegistry;
25+
import io.micrometer.core.instrument.Tag;
26+
import io.micrometer.core.instrument.Tags;
27+
import io.micrometer.core.instrument.Timer;
28+
29+
import org.springframework.lang.Nullable;
30+
import org.springframework.retry.RetryCallback;
31+
import org.springframework.retry.RetryContext;
32+
import org.springframework.retry.RetryListener;
33+
import org.springframework.util.Assert;
34+
35+
/**
36+
* The {@link RetryListener} implementation for Micrometer {@link Timer}s around retry
37+
* operations.
38+
* <p>
39+
* The {@link Timer#start} is called from the {@link #open(RetryContext, RetryCallback)}
40+
* and stopped in the {@link #close(RetryContext, RetryCallback, Throwable)}. This
41+
* {@link Timer.Sample} is associated with the provided {@link RetryContext} to make this
42+
* {@link MetricsRetryListener} instance reusable for many retry operation.
43+
* <p>
44+
* The registered {@value #TIMER_NAME} {@link Timer} has these tags by default:
45+
* <ul>
46+
* <li>{@code name} - {@link RetryCallback#getLabel()}</li>
47+
* <li>{@code retry.count} - the number of attempts - 1; essentially the successful first
48+
* call means no counts</li>
49+
* <li>{@code exception} - the thrown back to the caller (after all the retry attempts)
50+
* exception class name</li>
51+
* </ul>
52+
* <p>
53+
* The {@link #setCustomTags(Iterable)} and {@link #setCustomTagsProvider(Function)} can
54+
* be used to further customize tags on the timers.
55+
*
56+
* @author Artem Bilan
57+
* @since 2.0.8
58+
*/
59+
public class MetricsRetryListener implements RetryListener {
60+
61+
public static final String TIMER_NAME = "spring.retry";
62+
63+
private final MeterRegistry meterRegistry;
64+
65+
private final Map<RetryContext, Timer.Sample> retryContextToSample = new IdentityHashMap<>();
66+
67+
private final Meter.MeterProvider<Timer> retryMeterProvider;
68+
69+
private Tags customTags = Tags.empty();
70+
71+
private Function<RetryContext, Iterable<Tag>> customTagsProvider = retryContext -> Tags.empty();
72+
73+
/**
74+
* Construct an instance based on the provided {@link MeterRegistry}.
75+
* @param meterRegistry the {@link MeterRegistry} to use for timers.
76+
*/
77+
public MetricsRetryListener(MeterRegistry meterRegistry) {
78+
Assert.notNull(meterRegistry, "'meterRegistry' must not be null");
79+
this.meterRegistry = meterRegistry;
80+
this.retryMeterProvider = Timer.builder(TIMER_NAME)
81+
.description("Metrics for Spring RetryTemplate")
82+
.withRegistry(this.meterRegistry);
83+
}
84+
85+
/**
86+
* Supply tags which are going to be used for all the timers managed by this listener.
87+
* @param customTags the list of additional tags for all the timers.
88+
*/
89+
public void setCustomTags(@Nullable Iterable<Tag> customTags) {
90+
this.customTags = this.customTags.and(customTags);
91+
}
92+
93+
/**
94+
* Supply a {@link Function} to build additional tags for all the timers based on the
95+
* {@link RetryContext}.
96+
* @param customTagsProvider the {@link Function} for additional tags with a
97+
* {@link RetryContext} scope.
98+
*/
99+
public void setCustomTagsProvider(Function<RetryContext, Iterable<Tag>> customTagsProvider) {
100+
Assert.notNull(customTagsProvider, "'customTagsProvider' must not be null");
101+
this.customTagsProvider = customTagsProvider;
102+
}
103+
104+
@Override
105+
public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
106+
this.retryContextToSample.put(context, Timer.start(this.meterRegistry));
107+
return true;
108+
}
109+
110+
@Override
111+
public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback,
112+
@Nullable Throwable throwable) {
113+
114+
Timer.Sample sample = this.retryContextToSample.remove(context);
115+
116+
Assert.state(sample != null,
117+
() -> String.format("No 'Timer.Sample' registered for '%s'. Was the 'open()' called?", context));
118+
119+
Tags retryTags = Tags.of("name", callback.getLabel())
120+
.and("retry.count", "" + context.getRetryCount())
121+
.and(this.customTags)
122+
.and(this.customTagsProvider.apply(context))
123+
.and("exception", throwable != null ? throwable.getClass().getSimpleName() : "none");
124+
125+
sample.stop(this.retryMeterProvider.withTags(retryTags));
126+
}
127+
128+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/*
2+
* Copyright 2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.retry.support;
18+
19+
import io.micrometer.core.instrument.MeterRegistry;
20+
import io.micrometer.core.instrument.Tags;
21+
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
22+
import org.junit.jupiter.api.Test;
23+
24+
import org.springframework.beans.factory.annotation.Autowired;
25+
import org.springframework.context.annotation.Bean;
26+
import org.springframework.context.annotation.Configuration;
27+
import org.springframework.retry.RetryException;
28+
import org.springframework.retry.annotation.EnableRetry;
29+
import org.springframework.retry.annotation.Retryable;
30+
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
31+
32+
import static org.assertj.core.api.Assertions.assertThat;
33+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
34+
import static org.assertj.core.api.Assertions.assertThatNoException;
35+
36+
/**
37+
* @author Artem Bilan
38+
* @since 2.0.8
39+
*/
40+
@SpringJUnitConfig
41+
public class RetryMetricsTests {
42+
43+
@Autowired
44+
MeterRegistry meterRegistry;
45+
46+
@Autowired
47+
Service service;
48+
49+
@Test
50+
void metricsAreCollectedForRetryable() {
51+
assertThatNoException().isThrownBy(this.service::service1);
52+
assertThatNoException().isThrownBy(this.service::service1);
53+
assertThatNoException().isThrownBy(this.service::service2);
54+
assertThatExceptionOfType(RetryException.class).isThrownBy(this.service::service3);
55+
56+
assertThat(this.meterRegistry.get(MetricsRetryListener.TIMER_NAME)
57+
.tags(Tags.of("name", "org.springframework.retry.support.RetryMetricsTests$Service.service1", "retry.count",
58+
"0", "exception", "none"))
59+
.timer()
60+
.count()).isEqualTo(2);
61+
62+
assertThat(this.meterRegistry.get(MetricsRetryListener.TIMER_NAME)
63+
.tags(Tags.of("name", "org.springframework.retry.support.RetryMetricsTests$Service.service2", "retry.count",
64+
"2", "exception", "none"))
65+
.timer()
66+
.count()).isEqualTo(1);
67+
68+
assertThat(this.meterRegistry.get(MetricsRetryListener.TIMER_NAME)
69+
.tags(Tags.of("name", "org.springframework.retry.support.RetryMetricsTests$Service.service3", "retry.count",
70+
"3", "exception", "RetryException"))
71+
.timer()
72+
.count()).isEqualTo(1);
73+
}
74+
75+
@Configuration(proxyBeanMethods = false)
76+
@EnableRetry
77+
public static class TestConfiguration {
78+
79+
@Bean
80+
MeterRegistry meterRegistry() {
81+
return new SimpleMeterRegistry();
82+
}
83+
84+
@Bean
85+
MetricsRetryListener metricsRetryListener(MeterRegistry meterRegistry) {
86+
return new MetricsRetryListener(meterRegistry);
87+
}
88+
89+
@Bean
90+
Service service() {
91+
return new Service();
92+
}
93+
94+
}
95+
96+
protected static class Service {
97+
98+
private int count = 0;
99+
100+
@Retryable
101+
public void service1() {
102+
103+
}
104+
105+
@Retryable
106+
public void service2() {
107+
if (count++ < 2) {
108+
throw new RuntimeException("Planned");
109+
}
110+
}
111+
112+
@Retryable
113+
public void service3() {
114+
throw new RetryException("Planned");
115+
}
116+
117+
}
118+
119+
}

0 commit comments

Comments
 (0)