Skip to content

Commit

Permalink
GH-458: Introduce MetricsRetryListener
Browse files Browse the repository at this point in the history
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
artembilan authored Aug 13, 2024
1 parent 8e5cafe commit 9d8df3b
Show file tree
Hide file tree
Showing 4 changed files with 262 additions and 0 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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>
Expand Down
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 src/test/java/org/springframework/retry/support/RetryMetricsTests.java
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");
}

}

}

0 comments on commit 9d8df3b

Please sign in to comment.