-
Notifications
You must be signed in to change notification settings - Fork 518
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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
- Loading branch information
1 parent
8e5cafe
commit 9d8df3b
Showing
4 changed files
with
262 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
128 changes: 128 additions & 0 deletions
128
src/main/java/org/springframework/retry/support/MetricsRetryListener.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)); | ||
} | ||
|
||
} |
119 changes: 119 additions & 0 deletions
119
src/test/java/org/springframework/retry/support/RetryMetricsTests.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"); | ||
} | ||
|
||
} | ||
|
||
} |