Instrument Scheduled methods for observability
This commit enhances the `ScheduledAnnotationBeanPostProcessor` to instrument `@Scheduled` methods declared on beans. This will create `"tasks.scheduled.execution"` observations for each execution of a scheduled method. This supports both blocking and reactive variants. By default, observations are no-ops; developers must configure the current `ObservationRegistry` on the `ScheduledTaskRegistrar` by using a `SchedulingConfigurer`. Closes gh-29883
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* Copyright 2002-2023 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.scheduling.annotation;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.micrometer.observation.Observation;
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import io.micrometer.observation.tck.TestObservationRegistry;
|
||||
import io.micrometer.observation.tck.TestObservationRegistryAssert;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.core.observability.DefaultSignalListener;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.beans.factory.config.BeanDefinition;
|
||||
import org.springframework.beans.factory.support.RootBeanDefinition;
|
||||
import org.springframework.context.support.StaticApplicationContext;
|
||||
import org.springframework.core.task.SimpleAsyncTaskExecutor;
|
||||
import org.springframework.scheduling.config.ScheduledTask;
|
||||
import org.springframework.scheduling.config.ScheduledTaskHolder;
|
||||
import org.springframework.scheduling.config.ScheduledTaskObservationContext;
|
||||
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/**
|
||||
* Observability tests for {@link ScheduledAnnotationBeanPostProcessor}.
|
||||
*
|
||||
* @author Brian Clozel
|
||||
*/
|
||||
class ScheduledAnnotationBeanPostProcessorObservabilityTests {
|
||||
|
||||
private final StaticApplicationContext context = new StaticApplicationContext();
|
||||
|
||||
private final SimpleAsyncTaskExecutor taskExecutor = new SimpleAsyncTaskExecutor();
|
||||
|
||||
private final TestObservationRegistry observationRegistry = TestObservationRegistry.create();
|
||||
|
||||
@AfterEach
|
||||
void closeContext() {
|
||||
context.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRecordSuccessObservationsForTasks() throws Exception {
|
||||
registerScheduledBean(FixedDelayBean.class);
|
||||
runScheduledTaskAndAwait();
|
||||
assertThatTaskObservation().hasLowCardinalityKeyValue("outcome", "SUCCESS")
|
||||
.hasLowCardinalityKeyValue("method.name", "fixedDelay")
|
||||
.hasLowCardinalityKeyValue("target.type", "FixedDelayBean")
|
||||
.hasLowCardinalityKeyValue("exception", "none");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRecordFailureObservationsForTasksThrowing() throws Exception {
|
||||
registerScheduledBean(FixedDelayErrorBean.class);
|
||||
runScheduledTaskAndAwait();
|
||||
assertThatTaskObservation().hasLowCardinalityKeyValue("outcome", "ERROR")
|
||||
.hasLowCardinalityKeyValue("method.name", "error")
|
||||
.hasLowCardinalityKeyValue("target.type", "FixedDelayErrorBean")
|
||||
.hasLowCardinalityKeyValue("exception", "IllegalStateException");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRecordSuccessObservationsForReactiveTasks() throws Exception {
|
||||
registerScheduledBean(FixedDelayReactiveBean.class);
|
||||
runScheduledTaskAndAwait();
|
||||
assertThatTaskObservation().hasLowCardinalityKeyValue("outcome", "SUCCESS")
|
||||
.hasLowCardinalityKeyValue("method.name", "fixedDelay")
|
||||
.hasLowCardinalityKeyValue("target.type", "FixedDelayReactiveBean")
|
||||
.hasLowCardinalityKeyValue("exception", "none");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRecordFailureObservationsForReactiveTasksThrowing() throws Exception {
|
||||
registerScheduledBean(FixedDelayReactiveErrorBean.class);
|
||||
runScheduledTaskAndAwait();
|
||||
assertThatTaskObservation().hasLowCardinalityKeyValue("outcome", "ERROR")
|
||||
.hasLowCardinalityKeyValue("method.name", "error")
|
||||
.hasLowCardinalityKeyValue("target.type", "FixedDelayReactiveErrorBean")
|
||||
.hasLowCardinalityKeyValue("exception", "IllegalStateException");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRecordCancelledObservationsForTasks() throws Exception {
|
||||
registerScheduledBean(CancelledTaskBean.class);
|
||||
ScheduledTask scheduledTask = getScheduledTask();
|
||||
this.taskExecutor.execute(scheduledTask.getTask().getRunnable());
|
||||
context.getBean(TaskTester.class).await();
|
||||
scheduledTask.cancel();
|
||||
assertThatTaskObservation().hasLowCardinalityKeyValue("outcome", "UNKNOWN")
|
||||
.hasLowCardinalityKeyValue("method.name", "cancelled")
|
||||
.hasLowCardinalityKeyValue("target.type", "CancelledTaskBean")
|
||||
.hasLowCardinalityKeyValue("exception", "none");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRecordCancelledObservationsForReactiveTasks() throws Exception {
|
||||
registerScheduledBean(CancelledReactiveTaskBean.class);
|
||||
ScheduledTask scheduledTask = getScheduledTask();
|
||||
this.taskExecutor.execute(scheduledTask.getTask().getRunnable());
|
||||
context.getBean(TaskTester.class).await();
|
||||
scheduledTask.cancel();
|
||||
assertThatTaskObservation().hasLowCardinalityKeyValue("outcome", "UNKNOWN")
|
||||
.hasLowCardinalityKeyValue("method.name", "cancelled")
|
||||
.hasLowCardinalityKeyValue("target.type", "CancelledReactiveTaskBean")
|
||||
.hasLowCardinalityKeyValue("exception", "none");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHaveCurrentObservationInScope() throws Exception {
|
||||
registerScheduledBean(CurrentObservationBean.class);
|
||||
runScheduledTaskAndAwait();
|
||||
assertThatTaskObservation().hasLowCardinalityKeyValue("outcome", "SUCCESS")
|
||||
.hasLowCardinalityKeyValue("method.name", "hasCurrentObservation")
|
||||
.hasLowCardinalityKeyValue("target.type", "CurrentObservationBean")
|
||||
.hasLowCardinalityKeyValue("exception", "none");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHaveCurrentObservationInReactiveScope() throws Exception {
|
||||
registerScheduledBean(CurrentObservationReactiveBean.class);
|
||||
runScheduledTaskAndAwait();
|
||||
assertThatTaskObservation().hasLowCardinalityKeyValue("outcome", "SUCCESS")
|
||||
.hasLowCardinalityKeyValue("method.name", "hasCurrentObservation")
|
||||
.hasLowCardinalityKeyValue("target.type", "CurrentObservationReactiveBean")
|
||||
.hasLowCardinalityKeyValue("exception", "none");
|
||||
}
|
||||
|
||||
|
||||
private void registerScheduledBean(Class<?> beanClass) {
|
||||
BeanDefinition processorDefinition = new RootBeanDefinition(ScheduledAnnotationBeanPostProcessor.class);
|
||||
BeanDefinition targetDefinition = new RootBeanDefinition(beanClass);
|
||||
targetDefinition.getPropertyValues().add("observationRegistry", this.observationRegistry);
|
||||
context.registerBeanDefinition("postProcessor", processorDefinition);
|
||||
context.registerBeanDefinition("target", targetDefinition);
|
||||
context.registerBean("schedulingConfigurer", SchedulingConfigurer.class, () -> {
|
||||
return new SchedulingConfigurer() {
|
||||
@Override
|
||||
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
|
||||
taskRegistrar.setObservationRegistry(observationRegistry);
|
||||
}
|
||||
};
|
||||
});
|
||||
context.refresh();
|
||||
}
|
||||
|
||||
private ScheduledTask getScheduledTask() {
|
||||
ScheduledTaskHolder taskHolder = context.getBean("postProcessor", ScheduledTaskHolder.class);
|
||||
return taskHolder.getScheduledTasks().iterator().next();
|
||||
}
|
||||
|
||||
private void runScheduledTaskAndAwait() throws InterruptedException {
|
||||
ScheduledTask scheduledTask = getScheduledTask();
|
||||
try {
|
||||
scheduledTask.getTask().getRunnable().run();
|
||||
}
|
||||
catch (Throwable exc) {
|
||||
// ignore exceptions thrown by test tasks
|
||||
}
|
||||
context.getBean(TaskTester.class).await();
|
||||
}
|
||||
|
||||
private TestObservationRegistryAssert.TestObservationRegistryAssertReturningObservationContextAssert assertThatTaskObservation() {
|
||||
return TestObservationRegistryAssert.assertThat(this.observationRegistry)
|
||||
.hasObservationWithNameEqualTo("tasks.scheduled.execution").that();
|
||||
}
|
||||
|
||||
static abstract class TaskTester {
|
||||
|
||||
ObservationRegistry observationRegistry;
|
||||
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
public void setObservationRegistry(ObservationRegistry observationRegistry) {
|
||||
this.observationRegistry = observationRegistry;
|
||||
}
|
||||
|
||||
public void await() throws InterruptedException {
|
||||
this.latch.await(3, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
static class FixedDelayBean extends TaskTester {
|
||||
|
||||
@Scheduled(fixedDelay = 10_000, initialDelay = 5_000)
|
||||
public void fixedDelay() {
|
||||
this.latch.countDown();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
static class FixedDelayErrorBean extends TaskTester {
|
||||
|
||||
@Scheduled(fixedDelay = 10_000, initialDelay = 5_000)
|
||||
public void error() {
|
||||
this.latch.countDown();
|
||||
throw new IllegalStateException("test error");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static class FixedDelayReactiveBean extends TaskTester {
|
||||
|
||||
@Scheduled(fixedDelay = 10_000, initialDelay = 5_000)
|
||||
public Mono<Object> fixedDelay() {
|
||||
return Mono.empty().doOnTerminate(() -> this.latch.countDown());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static class FixedDelayReactiveErrorBean extends TaskTester {
|
||||
|
||||
@Scheduled(fixedDelay = 10_000, initialDelay = 5_000)
|
||||
public Mono<Object> error() {
|
||||
return Mono.error(new IllegalStateException("test error"))
|
||||
.doOnTerminate(() -> this.latch.countDown());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static class CancelledTaskBean extends TaskTester {
|
||||
|
||||
@Scheduled(fixedDelay = 10_000, initialDelay = 5_000)
|
||||
public void cancelled() {
|
||||
this.latch.countDown();
|
||||
try {
|
||||
Thread.sleep(5000);
|
||||
}
|
||||
catch (InterruptedException exc) {
|
||||
// ignore cancelled task
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static class CancelledReactiveTaskBean extends TaskTester {
|
||||
|
||||
@Scheduled(fixedDelay = 10_000, initialDelay = 5_000)
|
||||
public Flux<Long> cancelled() {
|
||||
return Flux.interval(Duration.ZERO, Duration.ofSeconds(1))
|
||||
.doOnNext(el -> this.latch.countDown());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static class CurrentObservationBean extends TaskTester {
|
||||
|
||||
@Scheduled(fixedDelay = 10_000, initialDelay = 5_000)
|
||||
public void hasCurrentObservation() {
|
||||
assertThat(this.observationRegistry.getCurrentObservation()).isNotNull();
|
||||
assertThat(this.observationRegistry.getCurrentObservation().getContext()).isInstanceOf(ScheduledTaskObservationContext.class);
|
||||
this.latch.countDown();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static class CurrentObservationReactiveBean extends TaskTester {
|
||||
|
||||
@Scheduled(fixedDelay = 10_000, initialDelay = 5_000)
|
||||
public Mono<String> hasCurrentObservation() {
|
||||
return Mono.just("test")
|
||||
.tap(() -> new DefaultSignalListener<String>() {
|
||||
@Override
|
||||
public void doFirst() throws Throwable {
|
||||
Observation observation = observationRegistry.getCurrentObservation();
|
||||
assertThat(observation).isNotNull();
|
||||
assertThat(observation.getContext()).isInstanceOf(ScheduledTaskObservationContext.class);
|
||||
}
|
||||
})
|
||||
.doOnTerminate(() -> this.latch.countDown());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import io.micrometer.observation.ObservationRegistry;
|
||||
import io.reactivex.rxjava3.core.Completable;
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -42,6 +43,7 @@ import static org.springframework.scheduling.annotation.ScheduledAnnotationReact
|
||||
import static org.springframework.scheduling.annotation.ScheduledAnnotationReactiveSupport.isReactive;
|
||||
|
||||
/**
|
||||
* Tests for {@link ScheduledAnnotationReactiveSupportTests}.
|
||||
* @author Simon Baslé
|
||||
* @since 6.1
|
||||
*/
|
||||
@@ -116,12 +118,12 @@ class ScheduledAnnotationReactiveSupportTests {
|
||||
Scheduled fixedDelayLong = AnnotationUtils.synthesizeAnnotation(Map.of("fixedDelay", 123L), Scheduled.class, null);
|
||||
List<Runnable> tracker = new ArrayList<>();
|
||||
|
||||
assertThat(createSubscriptionRunnable(m, target, fixedDelayString, tracker))
|
||||
assertThat(createSubscriptionRunnable(m, target, fixedDelayString, () -> ObservationRegistry.NOOP, tracker))
|
||||
.isInstanceOfSatisfying(ScheduledAnnotationReactiveSupport.SubscribingRunnable.class, sr ->
|
||||
assertThat(sr.shouldBlock).as("fixedDelayString.shouldBlock").isTrue()
|
||||
);
|
||||
|
||||
assertThat(createSubscriptionRunnable(m, target, fixedDelayLong, tracker))
|
||||
assertThat(createSubscriptionRunnable(m, target, fixedDelayLong, () -> ObservationRegistry.NOOP, tracker))
|
||||
.isInstanceOfSatisfying(ScheduledAnnotationReactiveSupport.SubscribingRunnable.class, sr ->
|
||||
assertThat(sr.shouldBlock).as("fixedDelayLong.shouldBlock").isTrue()
|
||||
);
|
||||
@@ -135,12 +137,12 @@ class ScheduledAnnotationReactiveSupportTests {
|
||||
Scheduled fixedRateLong = AnnotationUtils.synthesizeAnnotation(Map.of("fixedRate", 123L), Scheduled.class, null);
|
||||
List<Runnable> tracker = new ArrayList<>();
|
||||
|
||||
assertThat(createSubscriptionRunnable(m, target, fixedRateString, tracker))
|
||||
assertThat(createSubscriptionRunnable(m, target, fixedRateString, () -> ObservationRegistry.NOOP, tracker))
|
||||
.isInstanceOfSatisfying(ScheduledAnnotationReactiveSupport.SubscribingRunnable.class, sr ->
|
||||
assertThat(sr.shouldBlock).as("fixedRateString.shouldBlock").isFalse()
|
||||
);
|
||||
|
||||
assertThat(createSubscriptionRunnable(m, target, fixedRateLong, tracker))
|
||||
assertThat(createSubscriptionRunnable(m, target, fixedRateLong, () -> ObservationRegistry.NOOP, tracker))
|
||||
.isInstanceOfSatisfying(ScheduledAnnotationReactiveSupport.SubscribingRunnable.class, sr ->
|
||||
assertThat(sr.shouldBlock).as("fixedRateLong.shouldBlock").isFalse()
|
||||
);
|
||||
@@ -153,7 +155,7 @@ class ScheduledAnnotationReactiveSupportTests {
|
||||
Scheduled cron = AnnotationUtils.synthesizeAnnotation(Map.of("cron", "-"), Scheduled.class, null);
|
||||
List<Runnable> tracker = new ArrayList<>();
|
||||
|
||||
assertThat(createSubscriptionRunnable(m, target, cron, tracker))
|
||||
assertThat(createSubscriptionRunnable(m, target, cron, () -> ObservationRegistry.NOOP, tracker))
|
||||
.isInstanceOfSatisfying(ScheduledAnnotationReactiveSupport.SubscribingRunnable.class, sr ->
|
||||
assertThat(sr.shouldBlock).as("cron.shouldBlock").isFalse()
|
||||
);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright 2002-2023 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.scheduling.config;
|
||||
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import io.micrometer.common.KeyValue;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.aop.target.SingletonTargetSource;
|
||||
import org.springframework.util.ClassUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link DefaultScheduledTaskObservationConvention}.
|
||||
*/
|
||||
class DefaultScheduledTaskObservationConventionTests {
|
||||
|
||||
private final Method taskMethod = ClassUtils.getMethod(BeanWithScheduledMethods.class, "process");
|
||||
|
||||
private final ScheduledTaskObservationConvention convention = new DefaultScheduledTaskObservationConvention();
|
||||
|
||||
@Test
|
||||
void observationShouldHaveDefaultName() {
|
||||
assertThat(convention.getName()).isEqualTo("tasks.scheduled.execution");
|
||||
}
|
||||
|
||||
@Test
|
||||
void observationShouldHaveContextualName() {
|
||||
ScheduledTaskObservationContext context = new ScheduledTaskObservationContext(new BeanWithScheduledMethods(), taskMethod);
|
||||
assertThat(convention.getContextualName(context)).isEqualTo("task beanWithScheduledMethods.process");
|
||||
}
|
||||
|
||||
@Test
|
||||
void observationShouldHaveContextualNameForProxiedClass() {
|
||||
Object proxy = ProxyFactory.getProxy(new SingletonTargetSource(new BeanWithScheduledMethods()));
|
||||
ScheduledTaskObservationContext context = new ScheduledTaskObservationContext(proxy, taskMethod);
|
||||
assertThat(convention.getContextualName(context)).isEqualTo("task beanWithScheduledMethods.process");
|
||||
}
|
||||
|
||||
@Test
|
||||
void observationShouldHaveTargetType() {
|
||||
ScheduledTaskObservationContext context = new ScheduledTaskObservationContext(new BeanWithScheduledMethods(), taskMethod);
|
||||
assertThat(convention.getLowCardinalityKeyValues(context)).contains(KeyValue.of("target.type", "BeanWithScheduledMethods"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void observationShouldHaveMethodName() {
|
||||
ScheduledTaskObservationContext context = new ScheduledTaskObservationContext(new BeanWithScheduledMethods(), taskMethod);
|
||||
assertThat(convention.getLowCardinalityKeyValues(context)).contains(KeyValue.of("method.name", "process"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void observationShouldHaveSuccessfulOutcome() {
|
||||
ScheduledTaskObservationContext context = new ScheduledTaskObservationContext(new BeanWithScheduledMethods(), taskMethod);
|
||||
context.setComplete(true);
|
||||
assertThat(convention.getLowCardinalityKeyValues(context)).contains(KeyValue.of("outcome", "SUCCESS"),
|
||||
KeyValue.of("exception", "none"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void observationShouldHaveErrorOutcome() {
|
||||
ScheduledTaskObservationContext context = new ScheduledTaskObservationContext(new BeanWithScheduledMethods(), taskMethod);
|
||||
context.setError(new IllegalStateException("test error"));
|
||||
assertThat(convention.getLowCardinalityKeyValues(context)).contains(KeyValue.of("outcome", "ERROR"),
|
||||
KeyValue.of("exception", "IllegalStateException"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void observationShouldHaveUnknownOutcome() {
|
||||
ScheduledTaskObservationContext context = new ScheduledTaskObservationContext(new BeanWithScheduledMethods(), taskMethod);
|
||||
assertThat(convention.getLowCardinalityKeyValues(context)).contains(KeyValue.of("outcome", "UNKNOWN"),
|
||||
KeyValue.of("exception", "none"));
|
||||
}
|
||||
|
||||
|
||||
static class BeanWithScheduledMethods implements TaskProcessor {
|
||||
|
||||
public void process() {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
interface TaskProcessor {
|
||||
|
||||
void process();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user