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:
Brian Clozel
2023-06-19 08:55:08 +02:00
parent 842569c9e5
commit 09cb844421
14 changed files with 900 additions and 24 deletions

View File

@@ -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());
}
}
}

View File

@@ -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()
);

View File

@@ -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();
}
}