From 09cb844421db3bd3475a5bdb0de244775f4c8185 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Mon, 19 Jun 2023 08:55:08 +0200 Subject: [PATCH] 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 --- .../ROOT/pages/integration/observability.adoc | 32 +- .../ObservationSchedulingConfigurer.java | 38 +++ spring-context/spring-context.gradle | 3 + .../ScheduledAnnotationBeanPostProcessor.java | 7 +- .../ScheduledAnnotationReactiveSupport.java | 65 +++- ...ultScheduledTaskObservationConvention.java | 84 +++++ .../ScheduledTaskObservationContext.java | 80 +++++ .../ScheduledTaskObservationConvention.java | 35 +++ ...ScheduledTaskObservationDocumentation.java | 100 ++++++ .../config/ScheduledTaskRegistrar.java | 22 ++ .../support/ScheduledMethodRunnable.java | 41 ++- ...onBeanPostProcessorObservabilityTests.java | 296 ++++++++++++++++++ ...heduledAnnotationReactiveSupportTests.java | 12 +- ...heduledTaskObservationConventionTests.java | 107 +++++++ 14 files changed, 899 insertions(+), 23 deletions(-) create mode 100644 framework-docs/src/main/java/org/springframework/docs/integration/observability/tasksscheduled/ObservationSchedulingConfigurer.java create mode 100644 spring-context/src/main/java/org/springframework/scheduling/config/DefaultScheduledTaskObservationConvention.java create mode 100644 spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskObservationContext.java create mode 100644 spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskObservationConvention.java create mode 100644 spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskObservationDocumentation.java create mode 100644 spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessorObservabilityTests.java create mode 100644 spring-context/src/test/java/org/springframework/scheduling/config/DefaultScheduledTaskObservationConventionTests.java diff --git a/framework-docs/modules/ROOT/pages/integration/observability.adoc b/framework-docs/modules/ROOT/pages/integration/observability.adoc index dc5230e49b..412bb54406 100644 --- a/framework-docs/modules/ROOT/pages/integration/observability.adoc +++ b/framework-docs/modules/ROOT/pages/integration/observability.adoc @@ -21,11 +21,14 @@ As outlined xref:integration/observability.adoc[at the beginning of this section |=== |Observation name |Description -|xref:integration/observability.adoc#http-client[`"http.client.requests"`] +|xref:integration/observability.adoc#observability.http-client[`"http.client.requests"`] |Time spent for HTTP client exchanges -|xref:integration/observability.adoc#http-server[`"http.server.requests"`] +|xref:integration/observability.adoc#observability.http-server[`"http.server.requests"`] |Processing time for HTTP server exchanges at the Framework level + +|xref:integration/observability.adoc#observability.tasks-scheduled[`"tasks.scheduled.execution"`] +|Processing time for an execution of a `@Scheduled` task |=== NOTE: Observations are using Micrometer's official naming convention, but Metrics names will be automatically converted @@ -79,6 +82,31 @@ include-code::./ServerRequestObservationFilter[] You can configure `ObservationFilter` instances on the `ObservationRegistry`. +[[observability.tasks-scheduled]] +== @Scheduled tasks instrumentation + +An Observation is created for xref:integration/scheduling.adoc#scheduling-enable-annotation-support[each execution of an `@Scheduled` task]. +Applications need to configure the `ObservationRegistry` on the `ScheduledTaskRegistrar` to enable the recording of observations. +This can be done by declaring a `SchedulingConfigurer` bean that sets the observation registry: + +include-code::./ObservationSchedulingConfigurer[] + +It is using the `org.springframework.scheduling.config.DefaultScheduledTaskObservationConvention` by default, backed by the `ScheduledTaskObservationContext`. +You can configure a custom implementation on the `ObservationRegistry` directly. +During the execution of the scheduled method, the current observation is restored in the `ThreadLocal` context or the Reactor context (if the scheduled method returns a `Mono` or `Flux` type). + +By default, the following `KeyValues` are created: + +.Low cardinality Keys +[cols="a,a"] +|=== +|Name | Description +|`exception` _(required)_|Name of the exception thrown during the execution, or `KeyValue#NONE_VALUE`} if no exception happened. +|`method.name` _(required)_|Name of Java `Method` that is scheduled for execution. +|`outcome` _(required)_|Outcome of the method execution. Can be `"SUCCESS"`, `"ERROR"` or `"UNKNOWN"` (if for example the operation was cancelled during execution. +|`target.type` _(required)_|Simple class name of the bean instance that holds the scheduled method. +|=== + [[observability.http-server]] == HTTP Server instrumentation diff --git a/framework-docs/src/main/java/org/springframework/docs/integration/observability/tasksscheduled/ObservationSchedulingConfigurer.java b/framework-docs/src/main/java/org/springframework/docs/integration/observability/tasksscheduled/ObservationSchedulingConfigurer.java new file mode 100644 index 0000000000..931f362890 --- /dev/null +++ b/framework-docs/src/main/java/org/springframework/docs/integration/observability/tasksscheduled/ObservationSchedulingConfigurer.java @@ -0,0 +1,38 @@ +/* + * 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.docs.integration.observability.tasksscheduled; + + +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +public class ObservationSchedulingConfigurer implements SchedulingConfigurer { + + private final ObservationRegistry observationRegistry; + + public ObservationSchedulingConfigurer(ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + } + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + taskRegistrar.setObservationRegistry(this.observationRegistry); + } + +} diff --git a/spring-context/spring-context.gradle b/spring-context/spring-context.gradle index 333ec1dd2a..cc3962a345 100644 --- a/spring-context/spring-context.gradle +++ b/spring-context/spring-context.gradle @@ -11,6 +11,7 @@ dependencies { api(project(":spring-beans")) api(project(":spring-core")) api(project(":spring-expression")) + api("io.micrometer:micrometer-observation") optional(project(":spring-instrument")) optional("jakarta.annotation:jakarta.annotation-api") optional("jakarta.ejb:jakarta.ejb-api") @@ -41,6 +42,8 @@ dependencies { testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor") testImplementation("io.reactivex.rxjava3:rxjava") + testImplementation('io.micrometer:context-propagation') + testImplementation("io.micrometer:micrometer-observation-test") testRuntimeOnly("jakarta.xml.bind:jakarta.xml.bind-api") testRuntimeOnly("org.glassfish:jakarta.el") // Substitute for javax.management:jmxremote_optional:1.0.1_04 (not available on Maven Central) diff --git a/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java b/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java index 7d34bd637e..5732f8432e 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java +++ b/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java @@ -414,6 +414,10 @@ public class ScheduledAnnotationBeanPostProcessor * accordingly. The Runnable can represent either a synchronous method invocation * (see {@link #processScheduledSync(Scheduled, Method, Object)}) or an asynchronous * one (see {@link #processScheduledAsync(Scheduled, Method, Object)}). + * @param scheduled the {@code @Scheduled} annotation + * @param runnable the runnable to be scheduled + * @param method the method that the annotation has been declared on + * @param bean the target bean instance */ protected void processScheduledTask(Scheduled scheduled, Runnable runnable, Method method, Object bean) { try { @@ -578,6 +582,7 @@ public class ScheduledAnnotationBeanPostProcessor Runnable task; try { task = ScheduledAnnotationReactiveSupport.createSubscriptionRunnable(method, bean, scheduled, + this.registrar::getObservationRegistry, this.reactiveSubscriptions.computeIfAbsent(bean, k -> new CopyOnWriteArrayList<>())); } catch (IllegalArgumentException ex) { @@ -598,7 +603,7 @@ public class ScheduledAnnotationBeanPostProcessor protected Runnable createRunnable(Object target, Method method) { Assert.isTrue(method.getParameterCount() == 0, "Only no-arg methods may be annotated with @Scheduled"); Method invocableMethod = AopUtils.selectInvocableMethod(method, target.getClass()); - return new ScheduledMethodRunnable(target, invocableMethod); + return new ScheduledMethodRunnable(target, invocableMethod, this.registrar::getObservationRegistry); } private static Duration toDuration(long value, TimeUnit timeUnit) { diff --git a/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationReactiveSupport.java b/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationReactiveSupport.java index b7232ab1f8..e6290d0989 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationReactiveSupport.java +++ b/spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationReactiveSupport.java @@ -20,7 +20,11 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.List; import java.util.concurrent.CountDownLatch; +import java.util.function.Supplier; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; +import io.micrometer.observation.contextpropagation.ObservationThreadLocalAccessor; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.reactivestreams.Publisher; @@ -34,16 +38,22 @@ import org.springframework.core.KotlinDetector; import org.springframework.core.ReactiveAdapter; import org.springframework.core.ReactiveAdapterRegistry; import org.springframework.lang.Nullable; +import org.springframework.scheduling.config.DefaultScheduledTaskObservationConvention; +import org.springframework.scheduling.config.ScheduledTaskObservationContext; +import org.springframework.scheduling.config.ScheduledTaskObservationConvention; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; +import static org.springframework.scheduling.config.ScheduledTaskObservationDocumentation.TASKS_SCHEDULED_EXECUTION; + /** * Helper class for @{@link ScheduledAnnotationBeanPostProcessor} to support reactive * cases without a dependency on optional classes. * * @author Simon Baslé + * @author Brian Clozel * @since 6.1 */ abstract class ScheduledAnnotationReactiveSupport { @@ -157,11 +167,12 @@ abstract class ScheduledAnnotationReactiveSupport { * delay is applied until the next iteration). */ static Runnable createSubscriptionRunnable(Method method, Object targetBean, Scheduled scheduled, - List subscriptionTrackerRegistry) { + Supplier observationRegistrySupplier, List subscriptionTrackerRegistry) { boolean shouldBlock = (scheduled.fixedDelay() > 0 || StringUtils.hasText(scheduled.fixedDelayString())); Publisher publisher = getPublisherFor(method, targetBean); - return new SubscribingRunnable(publisher, shouldBlock, subscriptionTrackerRegistry); + Supplier contextSupplier = () -> new ScheduledTaskObservationContext(targetBean, method); + return new SubscribingRunnable(publisher, shouldBlock, subscriptionTrackerRegistry, observationRegistrySupplier, contextSupplier); } @@ -173,23 +184,33 @@ abstract class ScheduledAnnotationReactiveSupport { private final Publisher publisher; + private static final ScheduledTaskObservationConvention DEFAULT_CONVENTION = new DefaultScheduledTaskObservationConvention(); + final boolean shouldBlock; private final List subscriptionTrackerRegistry; - SubscribingRunnable(Publisher publisher, boolean shouldBlock, List subscriptionTrackerRegistry) { + final Supplier observationRegistrySupplier; + + final Supplier contextSupplier; + + SubscribingRunnable(Publisher publisher, boolean shouldBlock, List subscriptionTrackerRegistry, + Supplier observationRegistrySupplier, Supplier contextSupplier) { this.publisher = publisher; this.shouldBlock = shouldBlock; this.subscriptionTrackerRegistry = subscriptionTrackerRegistry; + this.observationRegistrySupplier = observationRegistrySupplier; + this.contextSupplier = contextSupplier; } @Override public void run() { + Observation observation = TASKS_SCHEDULED_EXECUTION.observation(null, DEFAULT_CONVENTION, + this.contextSupplier, this.observationRegistrySupplier.get()); if (this.shouldBlock) { CountDownLatch latch = new CountDownLatch(1); - TrackingSubscriber subscriber = new TrackingSubscriber(this.subscriptionTrackerRegistry, latch); - this.subscriptionTrackerRegistry.add(subscriber); - this.publisher.subscribe(subscriber); + TrackingSubscriber subscriber = new TrackingSubscriber(this.subscriptionTrackerRegistry, observation, latch); + subscribe(subscriber, observation); try { latch.await(); } @@ -198,8 +219,19 @@ abstract class ScheduledAnnotationReactiveSupport { } } else { - TrackingSubscriber subscriber = new TrackingSubscriber(this.subscriptionTrackerRegistry); - this.subscriptionTrackerRegistry.add(subscriber); + TrackingSubscriber subscriber = new TrackingSubscriber(this.subscriptionTrackerRegistry, observation); + subscribe(subscriber, observation); + } + } + + private void subscribe(TrackingSubscriber subscriber, Observation observation) { + this.subscriptionTrackerRegistry.add(subscriber); + if (reactorPresent) { + Flux.from(this.publisher) + .contextWrite(context -> context.put(ObservationThreadLocalAccessor.KEY, observation)) + .subscribe(subscriber); + } + else { this.publisher.subscribe(subscriber); } } @@ -215,6 +247,8 @@ abstract class ScheduledAnnotationReactiveSupport { private final List subscriptionTrackerRegistry; + private final Observation observation; + @Nullable private final CountDownLatch blockingLatch; @@ -225,12 +259,13 @@ abstract class ScheduledAnnotationReactiveSupport { @Nullable private Subscription subscription; - TrackingSubscriber(List subscriptionTrackerRegistry) { - this(subscriptionTrackerRegistry, null); + TrackingSubscriber(List subscriptionTrackerRegistry, Observation observation) { + this(subscriptionTrackerRegistry, observation, null); } - TrackingSubscriber(List subscriptionTrackerRegistry, @Nullable CountDownLatch latch) { + TrackingSubscriber(List subscriptionTrackerRegistry, Observation observation, @Nullable CountDownLatch latch) { this.subscriptionTrackerRegistry = subscriptionTrackerRegistry; + this.observation = observation; this.blockingLatch = latch; } @@ -238,6 +273,7 @@ abstract class ScheduledAnnotationReactiveSupport { public void run() { if (this.subscription != null) { this.subscription.cancel(); + this.observation.stop(); } if (this.blockingLatch != null) { this.blockingLatch.countDown(); @@ -247,6 +283,7 @@ abstract class ScheduledAnnotationReactiveSupport { @Override public void onSubscribe(Subscription subscription) { this.subscription = subscription; + this.observation.start(); subscription.request(Integer.MAX_VALUE); } @@ -259,6 +296,8 @@ abstract class ScheduledAnnotationReactiveSupport { public void onError(Throwable ex) { this.subscriptionTrackerRegistry.remove(this); logger.warn("Unexpected error occurred in scheduled reactive task", ex); + this.observation.error(ex); + this.observation.stop(); if (this.blockingLatch != null) { this.blockingLatch.countDown(); } @@ -267,6 +306,10 @@ abstract class ScheduledAnnotationReactiveSupport { @Override public void onComplete() { this.subscriptionTrackerRegistry.remove(this); + if (this.observation.getContext() instanceof ScheduledTaskObservationContext context) { + context.setComplete(true); + } + this.observation.stop(); if (this.blockingLatch != null) { this.blockingLatch.countDown(); } diff --git a/spring-context/src/main/java/org/springframework/scheduling/config/DefaultScheduledTaskObservationConvention.java b/spring-context/src/main/java/org/springframework/scheduling/config/DefaultScheduledTaskObservationConvention.java new file mode 100644 index 0000000000..5ff2eb7641 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/scheduling/config/DefaultScheduledTaskObservationConvention.java @@ -0,0 +1,84 @@ +/* + * 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 io.micrometer.common.KeyValue; +import io.micrometer.common.KeyValues; + +import org.springframework.util.StringUtils; + +import static org.springframework.scheduling.config.ScheduledTaskObservationDocumentation.LowCardinalityKeyNames; + +/** + * Default implementation for {@link ScheduledTaskObservationConvention}. + * @author Brian Clozel + * @since 6.1.0 + */ +public class DefaultScheduledTaskObservationConvention implements ScheduledTaskObservationConvention { + + private static final String DEFAULT_NAME = "tasks.scheduled.execution"; + + private static final KeyValue EXCEPTION_NONE = KeyValue.of(LowCardinalityKeyNames.EXCEPTION, KeyValue.NONE_VALUE); + + private static final KeyValue OUTCOME_SUCCESS = KeyValue.of(LowCardinalityKeyNames.OUTCOME, "SUCCESS"); + + private static final KeyValue OUTCOME_ERROR = KeyValue.of(LowCardinalityKeyNames.OUTCOME, "ERROR"); + + private static final KeyValue OUTCOME_UNKNOWN = KeyValue.of(LowCardinalityKeyNames.OUTCOME, "UNKNOWN"); + + @Override + public String getName() { + return DEFAULT_NAME; + } + + @Override + public String getContextualName(ScheduledTaskObservationContext context) { + return "task " + StringUtils.uncapitalize(context.getTargetClass().getSimpleName()) + + "." + context.getMethod().getName(); + } + + @Override + public KeyValues getLowCardinalityKeyValues(ScheduledTaskObservationContext context) { + return KeyValues.of(exception(context), methodName(context), outcome(context), targetType(context)); + } + + protected KeyValue exception(ScheduledTaskObservationContext context) { + if (context.getError() != null) { + return KeyValue.of(LowCardinalityKeyNames.EXCEPTION, context.getError().getClass().getSimpleName()); + } + return EXCEPTION_NONE; + } + + protected KeyValue methodName(ScheduledTaskObservationContext context) { + return KeyValue.of(LowCardinalityKeyNames.METHOD_NAME, context.getMethod().getName()); + } + + protected KeyValue outcome(ScheduledTaskObservationContext context) { + if (context.getError() != null) { + return OUTCOME_ERROR; + } + else if (!context.isComplete()) { + return OUTCOME_UNKNOWN; + } + return OUTCOME_SUCCESS; + } + + protected KeyValue targetType(ScheduledTaskObservationContext context) { + return KeyValue.of(LowCardinalityKeyNames.TARGET_TYPE, context.getTargetClass().getSimpleName()); + } + +} diff --git a/spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskObservationContext.java b/spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskObservationContext.java new file mode 100644 index 0000000000..6a73daf512 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskObservationContext.java @@ -0,0 +1,80 @@ +/* + * 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.observation.Observation; + +import org.springframework.util.ClassUtils; + +/** + * Context that holds information for observation metadata collection + * during the {@link ScheduledTaskObservationDocumentation#TASKS_SCHEDULED_EXECUTION execution of scheduled tasks}. + * @author Brian Clozel + * @since 6.1.0 + */ +public class ScheduledTaskObservationContext extends Observation.Context { + + private final Class targetClass; + + private final Method method; + + private boolean complete; + + /** + * Create a new observation context for a task, given the target object + * and the method to be called. + * @param target the target object that is called for task execution + * @param method the method that is called for task execution + */ + public ScheduledTaskObservationContext(Object target, Method method) { + this.targetClass = ClassUtils.getUserClass(target); + this.method = method; + } + + /** + * Return the type of the target object. + */ + public Class getTargetClass() { + return this.targetClass; + } + + /** + * Return the method that is called for task execution. + */ + public Method getMethod() { + return this.method; + } + + /** + * Return whether the task execution is complete. + *

If an observation has ended and the task is not complete, this means + * that an {@link #getError() error} was raised or that the task execution got cancelled + * during its execution. + */ + public boolean isComplete() { + return this.complete; + } + + /** + * Set whether the task execution has completed. + */ + public void setComplete(boolean complete) { + this.complete = complete; + } +} diff --git a/spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskObservationConvention.java b/spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskObservationConvention.java new file mode 100644 index 0000000000..732fc18f39 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskObservationConvention.java @@ -0,0 +1,35 @@ +/* + * 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 io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; + +/** + * Interface for an {@link ObservationConvention} for + * {@link ScheduledTaskObservationDocumentation#TASKS_SCHEDULED_EXECUTION scheduled task executions}. + * @author Brian Clozel + * @since 6.1.0 + */ +public interface ScheduledTaskObservationConvention extends ObservationConvention { + + @Override + default boolean supportsContext(Observation.Context context) { + return context instanceof ScheduledTaskObservationContext; + } + +} diff --git a/spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskObservationDocumentation.java b/spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskObservationDocumentation.java new file mode 100644 index 0000000000..0f466a8b12 --- /dev/null +++ b/spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskObservationDocumentation.java @@ -0,0 +1,100 @@ +/* + * 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 io.micrometer.common.KeyValue; +import io.micrometer.common.docs.KeyName; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationConvention; +import io.micrometer.observation.docs.ObservationDocumentation; + +/** + * Documented {@link io.micrometer.common.KeyValue KeyValues} for the observations on + * executions of {@link org.springframework.scheduling.annotation.Scheduled scheduled tasks}. + *

This class is used by automated tools to document KeyValues attached to the {@code @Scheduled} observations. + * + * @author Brian Clozel + * @since 6.1.0 + */ +public enum ScheduledTaskObservationDocumentation implements ObservationDocumentation { + + /** + * Observations on executions of {@link org.springframework.scheduling.annotation.Scheduled} tasks. + */ + TASKS_SCHEDULED_EXECUTION { + @Override + public Class> getDefaultConvention() { + return DefaultScheduledTaskObservationConvention.class; + } + + @Override + public KeyName[] getLowCardinalityKeyNames() { + return LowCardinalityKeyNames.values(); + } + + @Override + public KeyName[] getHighCardinalityKeyNames() { + return new KeyName[] {}; + } + + }; + + public enum LowCardinalityKeyNames implements KeyName { + + /** + * {@link Class#getSimpleName() Simple name} of the target type that owns the scheduled method. + */ + TARGET_TYPE { + @Override + public String asString() { + return "target.type"; + } + }, + + /** + * Name of the method that is executed for the scheduled task. + */ + METHOD_NAME { + @Override + public String asString() { + return "method.name"; + } + }, + + /** + * Name of the exception thrown during task execution, or {@value KeyValue#NONE_VALUE} if no exception was thrown. + */ + EXCEPTION { + @Override + public String asString() { + return "exception"; + } + }, + + /** + * Outcome of the scheduled task execution. + */ + OUTCOME { + @Override + public String asString() { + return "outcome"; + } + } + + } + +} diff --git a/spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskRegistrar.java b/spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskRegistrar.java index 5ded560961..fb13ec6db0 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskRegistrar.java +++ b/spring-context/src/main/java/org/springframework/scheduling/config/ScheduledTaskRegistrar.java @@ -28,6 +28,8 @@ import java.util.Set; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import io.micrometer.observation.ObservationRegistry; + import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.InitializingBean; import org.springframework.lang.Nullable; @@ -53,6 +55,7 @@ import org.springframework.util.CollectionUtils; * @author Tobias Montagna-Hay * @author Sam Brannen * @author Arjen Poutsma + * @author Brian Clozel * @since 3.0 * @see org.springframework.scheduling.annotation.EnableAsync * @see org.springframework.scheduling.annotation.SchedulingConfigurer @@ -77,6 +80,9 @@ public class ScheduledTaskRegistrar implements ScheduledTaskHolder, Initializing @Nullable private ScheduledExecutorService localExecutor; + @Nullable + private ObservationRegistry observationRegistry; + @Nullable private List triggerTasks; @@ -130,6 +136,22 @@ public class ScheduledTaskRegistrar implements ScheduledTaskHolder, Initializing return this.taskScheduler; } + /** + * Return the {@link ObservationRegistry} for this registrar. + * @since 6.1.0 + */ + @Nullable + public ObservationRegistry getObservationRegistry() { + return this.observationRegistry; + } + + /** + * Configure an {@link ObservationRegistry} to record observations for scheduled tasks. + * @since 6.1.0 + */ + public void setObservationRegistry(@Nullable ObservationRegistry observationRegistry) { + this.observationRegistry = observationRegistry; + } /** * Specify triggered tasks as a Map of Runnables (the tasks) and Trigger objects diff --git a/spring-context/src/main/java/org/springframework/scheduling/support/ScheduledMethodRunnable.java b/spring-context/src/main/java/org/springframework/scheduling/support/ScheduledMethodRunnable.java index 4977226424..1e6ffb9336 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/support/ScheduledMethodRunnable.java +++ b/spring-context/src/main/java/org/springframework/scheduling/support/ScheduledMethodRunnable.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2018 the original author or authors. + * 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. @@ -19,7 +19,15 @@ package org.springframework.scheduling.support; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.UndeclaredThrowableException; +import java.util.function.Supplier; +import io.micrometer.observation.Observation; +import io.micrometer.observation.ObservationRegistry; + +import org.springframework.scheduling.config.DefaultScheduledTaskObservationConvention; +import org.springframework.scheduling.config.ScheduledTaskObservationContext; +import org.springframework.scheduling.config.ScheduledTaskObservationConvention; +import org.springframework.scheduling.config.ScheduledTaskObservationDocumentation; import org.springframework.util.ReflectionUtils; /** @@ -28,25 +36,42 @@ import org.springframework.util.ReflectionUtils; * assuming that an error strategy for Runnables is in place. * * @author Juergen Hoeller + * @author Brian Clozel * @since 3.0.6 * @see org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor */ public class ScheduledMethodRunnable implements Runnable { + private static final ScheduledTaskObservationConvention DEFAULT_CONVENTION = new DefaultScheduledTaskObservationConvention(); + private final Object target; private final Method method; + private final Supplier observationRegistrySupplier; /** * Create a {@code ScheduledMethodRunnable} for the given target instance, * calling the specified method. * @param target the target instance to call the method on * @param method the target method to call + * @param observationRegistrySupplier a supplier for the observation registry to use + * @since 6.1.0 */ - public ScheduledMethodRunnable(Object target, Method method) { + public ScheduledMethodRunnable(Object target, Method method, Supplier observationRegistrySupplier) { this.target = target; this.method = method; + this.observationRegistrySupplier = observationRegistrySupplier; + } + + /** + * Create a {@code ScheduledMethodRunnable} for the given target instance, + * calling the specified method. + * @param target the target instance to call the method on + * @param method the target method to call + */ + public ScheduledMethodRunnable(Object target, Method method) { + this(target, method, () -> ObservationRegistry.NOOP); } /** @@ -57,8 +82,7 @@ public class ScheduledMethodRunnable implements Runnable { * @throws NoSuchMethodException if the specified method does not exist */ public ScheduledMethodRunnable(Object target, String methodName) throws NoSuchMethodException { - this.target = target; - this.method = target.getClass().getMethod(methodName); + this(target, target.getClass().getMethod(methodName)); } @@ -79,9 +103,18 @@ public class ScheduledMethodRunnable implements Runnable { @Override public void run() { + ScheduledTaskObservationContext context = new ScheduledTaskObservationContext(this.target, this.method); + Observation observation = ScheduledTaskObservationDocumentation.TASKS_SCHEDULED_EXECUTION.observation( + null, DEFAULT_CONVENTION, + () -> context, this.observationRegistrySupplier.get()); + observation.observe(() -> runInternal(context)); + } + + private void runInternal(ScheduledTaskObservationContext context) { try { ReflectionUtils.makeAccessible(this.method); this.method.invoke(this.target); + context.setComplete(true); } catch (InvocationTargetException ex) { ReflectionUtils.rethrowRuntimeException(ex.getTargetException()); diff --git a/spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessorObservabilityTests.java b/spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessorObservabilityTests.java new file mode 100644 index 0000000000..5bb071b8f5 --- /dev/null +++ b/spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessorObservabilityTests.java @@ -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 fixedDelay() { + return Mono.empty().doOnTerminate(() -> this.latch.countDown()); + } + + } + + static class FixedDelayReactiveErrorBean extends TaskTester { + + @Scheduled(fixedDelay = 10_000, initialDelay = 5_000) + public Mono 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 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 hasCurrentObservation() { + return Mono.just("test") + .tap(() -> new DefaultSignalListener() { + @Override + public void doFirst() throws Throwable { + Observation observation = observationRegistry.getCurrentObservation(); + assertThat(observation).isNotNull(); + assertThat(observation.getContext()).isInstanceOf(ScheduledTaskObservationContext.class); + } + }) + .doOnTerminate(() -> this.latch.countDown()); + } + + } + +} diff --git a/spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationReactiveSupportTests.java b/spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationReactiveSupportTests.java index 9dda474e6d..15b874a2bd 100644 --- a/spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationReactiveSupportTests.java +++ b/spring-context/src/test/java/org/springframework/scheduling/annotation/ScheduledAnnotationReactiveSupportTests.java @@ -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 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 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 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() ); diff --git a/spring-context/src/test/java/org/springframework/scheduling/config/DefaultScheduledTaskObservationConventionTests.java b/spring-context/src/test/java/org/springframework/scheduling/config/DefaultScheduledTaskObservationConventionTests.java new file mode 100644 index 0000000000..bdf613380a --- /dev/null +++ b/spring-context/src/test/java/org/springframework/scheduling/config/DefaultScheduledTaskObservationConventionTests.java @@ -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(); + + } + +}