()V
public fun customize (Lorg/springframework/scheduling/quartz/SchedulerFactoryBean;)V
diff --git a/sentry-spring-7/build.gradle.kts b/sentry-spring-7/build.gradle.kts
index 90b7614867b..64c26019256 100644
--- a/sentry-spring-7/build.gradle.kts
+++ b/sentry-spring-7/build.gradle.kts
@@ -11,6 +11,9 @@ plugins {
alias(libs.plugins.gradle.versions)
alias(libs.plugins.buildconfig)
alias(libs.plugins.springboot4) apply false
+ id("io.sentry.spotless")
+ id("io.sentry.jdk-target")
+ id("io.sentry.publish")
id("io.sentry.api-validation")
}
@@ -33,7 +36,7 @@ dependencies {
compileOnly(Config.Libs.springWeb)
compileOnly(Config.Libs.springAop)
compileOnly(Config.Libs.springSecurityWeb)
- compileOnly(Config.Libs.aspectj)
+ compileOnly(libs.aspectjweaver)
compileOnly(libs.context.propagation)
compileOnly(libs.jetbrains.annotations)
compileOnly(libs.nopen.annotations)
diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java
index 47cd80ab5b2..ac17f85a985 100644
--- a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java
+++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java
@@ -29,6 +29,16 @@
*/
boolean heartbeat() default false;
+ /**
+ * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so
+ * Sentry creates or updates the monitor.
+ *
+ * Heartbeat check-ins never send a monitor config.
+ *
+ * @return true to send a monitor config, false by default
+ */
+ boolean upsertMonitorConfig() default false;
+
/**
* Monitor slug. If not set, no check-in will be sent.
*
diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java
index a14f0d4c96a..2f83a079d0d 100644
--- a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java
+++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryCheckInAdvice.java
@@ -6,13 +6,25 @@
import io.sentry.DateUtils;
import io.sentry.IScopes;
import io.sentry.ISentryLifecycleToken;
+import io.sentry.MonitorConfig;
import io.sentry.ScopesAdapter;
import io.sentry.SentryLevel;
import io.sentry.protocol.SentryId;
import io.sentry.time.Stopwatch;
+import io.sentry.util.MonitorConfigUtils;
import io.sentry.util.Objects;
import io.sentry.util.TracingUtils;
import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.format.DateTimeParseException;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+import java.util.zip.CRC32;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.jetbrains.annotations.ApiStatus;
@@ -20,7 +32,11 @@
import org.jetbrains.annotations.Nullable;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.EmbeddedValueResolverAware;
+import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.scheduling.annotation.Schedules;
+import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringValueResolver;
@@ -31,16 +47,44 @@
@ApiStatus.Internal
@Open
public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueResolverAware {
+ // Spring before 5.3 parses crons with CronSequenceGenerator
+ private static final boolean LEGACY_CRON_PARSER =
+ !ClassUtils.isPresent(
+ "org.springframework.scheduling.support.CronExpression",
+ SentryCheckInAdvice.class.getClassLoader());
+
+ private static final @NotNull Pattern WORD_BOUNDARY =
+ Pattern.compile("(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])");
+ private static final int MAX_SLUG_LENGTH = 50;
+
private final @NotNull IScopes scopes;
+ private final boolean monitorScheduledTasks;
+
private @Nullable StringValueResolver resolver;
+ private final @NotNull Map monitorConfigs =
+ new ConcurrentHashMap<>();
+
+ private final @NotNull Map derivedSlugOwners = new ConcurrentHashMap<>();
+
+ private final @NotNull Set collidingSlugs = ConcurrentHashMap.newKeySet();
+
public SentryCheckInAdvice() {
this(ScopesAdapter.getInstance());
}
public SentryCheckInAdvice(final @NotNull IScopes scopes) {
+ this(scopes, false);
+ }
+
+ /**
+ * @param monitorScheduledTasks whether to also monitor {@code @Scheduled} methods without {@link
+ * SentryCheckIn}, using a slug derived from the class and method name
+ */
+ public SentryCheckInAdvice(final @NotNull IScopes scopes, final boolean monitorScheduledTasks) {
this.scopes = Objects.requireNonNull(scopes, "scopes are required");
+ this.monitorScheduledTasks = monitorScheduledTasks;
}
@Override
@@ -54,6 +98,25 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) {
SentryCheckIn checkInAnnotation =
AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class);
if (checkInAnnotation == null) {
+ if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) {
+ final @NotNull Class> targetClass =
+ target != null ? target.getClass() : mostSpecificMethod.getDeclaringClass();
+ final @NotNull String taskName = scheduledTaskName(targetClass, mostSpecificMethod);
+ final @Nullable MonitorConfig monitorConfig = monitorConfig(mostSpecificMethod);
+ if (monitorConfig == null) {
+ scopes
+ .getOptions()
+ .getLogger()
+ .log(
+ SentryLevel.DEBUG,
+ "Not monitoring %s because Sentry Crons can't express its @Scheduled schedule.",
+ taskName);
+ return invocation.proceed();
+ }
+ final @NotNull String monitorSlug = scheduledTaskSlug(taskName);
+ warnOnSlugCollision(monitorSlug, targetClass, mostSpecificMethod);
+ return captureCheckIns(invocation, monitorSlug, false, monitorConfig);
+ }
return invocation.proceed();
}
@@ -89,6 +152,19 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) {
return invocation.proceed();
}
+ final @Nullable MonitorConfig monitorConfig =
+ !isHeartbeatOnly && checkInAnnotation.upsertMonitorConfig()
+ ? monitorConfig(mostSpecificMethod)
+ : null;
+ return captureCheckIns(invocation, monitorSlug, isHeartbeatOnly, monitorConfig);
+ }
+
+ private @Nullable Object captureCheckIns(
+ final @NotNull MethodInvocation invocation,
+ final @NotNull String monitorSlug,
+ final boolean isHeartbeatOnly,
+ final @Nullable MonitorConfig monitorConfig)
+ throws Throwable {
try (final @NotNull ISentryLifecycleToken ignored =
scopes.forkedScopes("SentryCheckInAdvice").makeCurrent()) {
TracingUtils.startNewTrace(scopes);
@@ -100,7 +176,9 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) {
try {
if (!isHeartbeatOnly) {
- checkInId = scopes.captureCheckIn(new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS));
+ final @NotNull CheckIn inProgress = new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS);
+ inProgress.setMonitorConfig(monitorConfig);
+ checkInId = scopes.captureCheckIn(inProgress);
}
return invocation.proceed();
} catch (Throwable e) {
@@ -115,6 +193,144 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) {
}
}
+ private void warnOnSlugCollision(
+ final @NotNull String monitorSlug,
+ final @NotNull Class> targetClass,
+ final @NotNull Method method) {
+ final @NotNull String identity =
+ ClassUtils.getUserClass(targetClass).getName() + "." + method.getName();
+ final @Nullable String owner = derivedSlugOwners.putIfAbsent(monitorSlug, identity);
+ if (owner != null && !owner.equals(identity) && collidingSlugs.add(monitorSlug)) {
+ scopes
+ .getOptions()
+ .getLogger()
+ .log(
+ SentryLevel.WARNING,
+ "Monitor slug \"%s\" is used by both %s and %s, so their check-ins go to one monitor. Add @SentryCheckIn(\"your-slug\") to one of them.",
+ monitorSlug,
+ owner,
+ identity);
+ }
+ }
+
+ static boolean isScheduled(final @NotNull Method method) {
+ return !AnnotatedElementUtils.getMergedRepeatableAnnotations(
+ method, Scheduled.class, Schedules.class)
+ .isEmpty();
+ }
+
+ /** {@code ClassName.methodName}, with nested classes as {@code Outer.Inner}. */
+ static @NotNull String scheduledTaskName(
+ final @NotNull Class> targetClass, final @NotNull Method method) {
+ return ClassUtils.getShortName(ClassUtils.getUserClass(targetClass)) + "." + method.getName();
+ }
+
+ static @NotNull String scheduledTaskSlug(final @NotNull String taskName) {
+ final @NotNull String slug =
+ WORD_BOUNDARY
+ .matcher(taskName)
+ .replaceAll("-")
+ .toLowerCase(Locale.ROOT)
+ .replaceAll("[^a-z0-9]+", "-")
+ .replaceAll("^-+|-+$", "");
+ if (slug.length() <= MAX_SLUG_LENGTH) {
+ return slug;
+ }
+ // keep truncated slugs unique
+ final @NotNull CRC32 crc = new CRC32();
+ crc.update(slug.getBytes(StandardCharsets.UTF_8));
+ final @NotNull String hash = String.format(Locale.ROOT, "%08x", crc.getValue());
+ return slug.substring(0, MAX_SLUG_LENGTH - hash.length() - 1).replaceAll("-+$", "")
+ + "-"
+ + hash;
+ }
+
+ private @Nullable MonitorConfig monitorConfig(final @NotNull Method method) {
+ return monitorConfigs.computeIfAbsent(
+ method, key -> new CachedMonitorConfig(monitorConfigFromScheduled(key)))
+ .config;
+ }
+
+ private @Nullable MonitorConfig monitorConfigFromScheduled(final @NotNull Method method) {
+ try {
+ final @NotNull Set schedules =
+ AnnotatedElementUtils.getMergedRepeatableAnnotations(
+ method, Scheduled.class, Schedules.class);
+ if (schedules.size() != 1) {
+ return null;
+ }
+ final @NotNull Scheduled scheduled = schedules.iterator().next();
+ // timeUnit only exists from Spring 5.3.10, so read it as an attribute
+ final @Nullable Object timeUnitAttribute =
+ AnnotationUtils.getAnnotationAttributes(scheduled).get("timeUnit");
+ final @NotNull TimeUnit timeUnit =
+ timeUnitAttribute instanceof TimeUnit unit ? unit : TimeUnit.MILLISECONDS;
+ final @Nullable String cron = resolve(scheduled.cron());
+ // Spring only reads the zone for a cron
+ final @Nullable String zone =
+ cron == null || cron.isEmpty() ? null : resolve(scheduled.zone());
+ return MonitorConfigUtils.fromSpringScheduled(
+ cron,
+ zone,
+ periodMillis(scheduled.fixedRate(), scheduled.fixedRateString(), timeUnit),
+ periodMillis(scheduled.fixedDelay(), scheduled.fixedDelayString(), timeUnit),
+ LEGACY_CRON_PARSER);
+ } catch (RuntimeException e) {
+ scopes
+ .getOptions()
+ .getLogger()
+ .log(SentryLevel.WARNING, "Could not derive a monitor config from @Scheduled.", e);
+ return null;
+ }
+ }
+
+ private @Nullable Long periodMillis(
+ final long value, final @NotNull String valueString, final @NotNull TimeUnit timeUnit) {
+ if (value >= 0) {
+ return timeUnit.toMillis(value);
+ }
+ final @Nullable String resolved = resolve(valueString);
+ if (resolved == null || resolved.isEmpty()) {
+ return null;
+ }
+ final @NotNull String trimmed = resolved.trim();
+ @Nullable Long millis = null;
+ if (trimmed.startsWith("P") || trimmed.startsWith("p")) {
+ try {
+ millis = Duration.parse(trimmed).toMillis();
+ } catch (DateTimeParseException | ArithmeticException e) {
+ // logged below
+ }
+ } else {
+ millis = MonitorConfigUtils.parsePeriodMillis(trimmed, timeUnit);
+ }
+ if (millis == null) {
+ scopes
+ .getOptions()
+ .getLogger()
+ .log(
+ SentryLevel.DEBUG,
+ "Not sending a monitor config because the @Scheduled period '%s' could not be parsed.",
+ trimmed);
+ }
+ return millis;
+ }
+
+ private @Nullable String resolve(final @NotNull String value) {
+ if (resolver == null || value.isEmpty()) {
+ return value;
+ }
+ return resolver.resolveStringValue(value);
+ }
+
+ private static final class CachedMonitorConfig {
+ private final @Nullable MonitorConfig config;
+
+ private CachedMonitorConfig(final @Nullable MonitorConfig config) {
+ this.config = config;
+ }
+ }
+
@Override
public void setEmbeddedValueResolver(StringValueResolver resolver) {
this.resolver = resolver;
diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryScheduledCheckInPointcut.java b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryScheduledCheckInPointcut.java
new file mode 100644
index 00000000000..2fc1f11da17
--- /dev/null
+++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryScheduledCheckInPointcut.java
@@ -0,0 +1,66 @@
+package io.sentry.spring7.checkin;
+
+import io.sentry.FilterString;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.List;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.springframework.aop.support.AopUtils;
+import org.springframework.aop.support.StaticMethodMatcherPointcut;
+import org.springframework.core.ReactiveAdapterRegistry;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Matches {@code @Scheduled} methods without {@link SentryCheckIn}, except excluded ones. Excludes
+ * match the task name ({@code ClassName.methodName}) exactly or as a regex.
+ */
+@ApiStatus.Internal
+public final class SentryScheduledCheckInPointcut extends StaticMethodMatcherPointcut {
+ private final @NotNull List excludedScheduledTasks = new ArrayList<>();
+
+ public SentryScheduledCheckInPointcut(final @Nullable List excludedScheduledTasks) {
+ if (excludedScheduledTasks != null) {
+ for (final @NotNull String task : excludedScheduledTasks) {
+ if (!task.trim().isEmpty()) {
+ this.excludedScheduledTasks.add(new FilterString(task.trim()));
+ }
+ }
+ }
+ }
+
+ @Override
+ public boolean matches(final @NotNull Method method, final @NotNull Class> targetClass) {
+ final @NotNull Class> userClass = ClassUtils.getUserClass(targetClass);
+ // CGLIB can't intercept these, and Spring calls private and static methods on the target
+ final int modifiers = method.getModifiers();
+ if (Modifier.isFinal(userClass.getModifiers())
+ || Modifier.isFinal(modifiers)
+ || Modifier.isPrivate(modifiers)
+ || Modifier.isStatic(modifiers)) {
+ return false;
+ }
+ final @NotNull Method specificMethod = AopUtils.getMostSpecificMethod(method, userClass);
+ if (!SentryCheckInAdvice.isScheduled(specificMethod)
+ || AnnotationUtils.findAnnotation(specificMethod, SentryCheckIn.class) != null) {
+ return false;
+ }
+ // Spring calls reactive and suspend methods once and subscribes to the result on each run
+ if (specificMethod.getParameterCount() > 0
+ || ReactiveAdapterRegistry.getSharedInstance().getAdapter(specificMethod.getReturnType())
+ != null) {
+ return false;
+ }
+ final @NotNull String taskName =
+ SentryCheckInAdvice.scheduledTaskName(userClass, specificMethod);
+ for (final @NotNull FilterString excluded : excludedScheduledTasks) {
+ if (excluded.getFilterString().equals(taskName) || excluded.matches(taskName)) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt
index e15affdbdd5..22e02b0b297 100644
--- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt
+++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt
@@ -4,16 +4,21 @@ import io.sentry.CheckIn
import io.sentry.CheckInStatus
import io.sentry.IScopes
import io.sentry.ISentryLifecycleToken
+import io.sentry.MonitorConfig
import io.sentry.Sentry
import io.sentry.SentryOptions
import io.sentry.protocol.SentryId
import io.sentry.spring7.checkin.SentryCheckIn
import io.sentry.spring7.checkin.SentryCheckInAdviceConfiguration
import io.sentry.spring7.checkin.SentryCheckInPointcutConfiguration
+import java.util.TimeZone
+import java.util.concurrent.TimeUnit
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertSame
import org.junit.jupiter.api.assertThrows
import org.junit.runner.RunWith
import org.mockito.kotlin.any
@@ -32,6 +37,8 @@ import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.EnableAspectJAutoProxy
import org.springframework.context.annotation.Import
import org.springframework.context.support.PropertySourcesPlaceholderConfigurer
+import org.springframework.scheduling.annotation.Scheduled
+import org.springframework.scheduling.annotation.Schedules
import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig
import org.springframework.test.context.junit4.SpringRunner
@@ -39,7 +46,15 @@ import org.springframework.util.StringValueResolver
@RunWith(SpringRunner::class)
@SpringJUnitConfig(SentryCheckInAdviceTest.Config::class)
-@TestPropertySource(properties = ["my.cron.slug = mypropertycronslug"])
+@TestPropertySource(
+ properties =
+ [
+ "my.cron.slug = mypropertycronslug",
+ "my.cron.schedule = 0 30 2 * * *",
+ "my.cron.zone = America/New_York",
+ "my.cron.empty.zone = ",
+ ]
+)
class SentryCheckInAdviceTest {
@Autowired lateinit var sampleService: SampleService
@@ -50,6 +65,10 @@ class SentryCheckInAdviceTest {
@Autowired lateinit var sampleServiceSpringProperties: SampleServiceSpringProperties
+ @Autowired lateinit var sampleServiceScheduled: SampleServiceScheduled
+
+ @Autowired lateinit var sampleServiceInheritedSchedule: SampleServiceInheritedSchedule
+
@Autowired lateinit var scopes: IScopes
val lifecycleToken = mock()
@@ -74,6 +93,7 @@ class SentryCheckInAdviceTest {
val inProgressCheckIn = checkInCaptor.firstValue
assertEquals("monitor_slug_1", inProgressCheckIn.monitorSlug)
assertEquals(CheckInStatus.IN_PROGRESS.apiName(), inProgressCheckIn.status)
+ assertNull(inProgressCheckIn.monitorConfig)
val doneCheckIn = checkInCaptor.lastValue
assertEquals("monitor_slug_1", doneCheckIn.monitorSlug)
@@ -228,6 +248,160 @@ class SentryCheckInAdviceTest {
order.verify(lifecycleToken).close()
}
+ @Test
+ fun `cron with zone is sent as crontab monitor config`() {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZone() }
+ assertNotNull(config)
+ assertEquals("crontab", config.schedule.type)
+ assertEquals("15 10 * * 1-5", config.schedule.value)
+ assertNull(config.schedule.unit)
+ assertEquals("Europe/Vienna", config.timezone)
+ }
+
+ @Test
+ fun `cron without zone is sent in the JVM default zone`() {
+ val defaultTimeZone = TimeZone.getDefault()
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo"))
+ try {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.cron() }
+ assertEquals("0 2 * * *", config?.schedule?.value)
+ assertEquals("Asia/Tokyo", config?.timezone)
+ } finally {
+ TimeZone.setDefault(defaultTimeZone)
+ }
+ }
+
+ @Test
+ fun `cron and zone placeholders are resolved`() {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.cronFromProperties() }
+ assertEquals("30 2 * * *", config?.schedule?.value)
+ assertEquals("America/New_York", config?.timezone)
+ }
+
+ @Test
+ fun `fixed rate with time unit is sent as interval monitor config`() {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() }
+ assertNotNull(config)
+ assertEquals("interval", config.schedule.type)
+ assertEquals("2", config.schedule.value)
+ assertEquals("hour", config.schedule.unit)
+ }
+
+ @Test
+ fun `ISO-8601 fixed rate string is sent as interval monitor config`() {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateIso() }
+ assertEquals("10", config?.schedule?.value)
+ assertEquals("minute", config?.schedule?.unit)
+ }
+
+ @Test
+ fun `simple duration fixed rate string is sent as interval monitor config`() {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateSimpleDuration() }
+ assertEquals("interval", config?.schedule?.type)
+ assertEquals("5", config?.schedule?.value)
+ assertEquals("minute", config?.schedule?.unit)
+ }
+
+ @Test
+ fun `fixed delay sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelay() })
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelayIso() })
+ }
+
+ @Test
+ fun `disabled cron sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.disabledCron() })
+ }
+
+ @Test
+ fun `zone placeholder resolving to empty uses the JVM default zone`() {
+ val defaultTimeZone = TimeZone.getDefault()
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo"))
+ try {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithEmptyZone() }
+ assertEquals("0 3 * * *", config?.schedule?.value)
+ assertEquals("Asia/Tokyo", config?.timezone)
+ } finally {
+ TimeZone.setDefault(defaultTimeZone)
+ }
+ }
+
+ @Test
+ fun `cron zone overrides the default timezone from options`() {
+ val options =
+ SentryOptions().apply {
+ cron = SentryOptions.Cron().apply { defaultTimezone = "Europe/Berlin" }
+ }
+ whenever(scopes.options).thenReturn(options)
+ val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZoneAndDefaults() }
+ assertEquals("Europe/Vienna", config?.timezone)
+ }
+
+ @Test
+ fun `unresolvable cron placeholder sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.unresolvableCron() })
+ }
+
+ @Test
+ fun `exception while deriving monitor config sends no monitor config and runs the job`() {
+ var result = 0
+ assertNull(inProgressMonitorConfig { result = sampleServiceScheduled.derivationThrows() })
+ assertEquals(1, result)
+ }
+
+ @Test
+ fun `monitor config is derived once per method`() {
+ val first = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() }
+ val second = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() }
+ assertNotNull(first)
+ assertSame(first, second)
+ }
+
+ @Test
+ fun `multiple schedules send no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.multipleSchedules() })
+ }
+
+ @Test
+ fun `schedules container sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.schedulesContainer() })
+ }
+
+ @Test
+ fun `upsertMonitorConfig false sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDisabled() })
+ }
+
+ @Test
+ fun `upsertMonitorConfig defaults to false`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDefault() })
+ }
+
+ @Test
+ fun `@Scheduled on an overridden parent method sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceInheritedSchedule.job() })
+ }
+
+ @Test
+ fun `heartbeat with @Scheduled sends a single check-in without monitor config`() {
+ val checkInCaptor = argumentCaptor()
+ whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId())
+ sampleServiceScheduled.heartbeat()
+ assertEquals(1, checkInCaptor.allValues.size)
+ assertEquals(CheckInStatus.OK.apiName(), checkInCaptor.firstValue.status)
+ assertNull(checkInCaptor.firstValue.monitorConfig)
+ }
+
+ private fun inProgressMonitorConfig(block: () -> Unit): MonitorConfig? {
+ val checkInCaptor = argumentCaptor()
+ whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId())
+ block()
+ assertEquals(2, checkInCaptor.allValues.size)
+ assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkInCaptor.firstValue.status)
+ assertNull(checkInCaptor.lastValue.monitorConfig)
+ return checkInCaptor.firstValue.monitorConfig
+ }
+
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true)
@Import(SentryCheckInAdviceConfiguration::class, SentryCheckInPointcutConfiguration::class)
@@ -241,6 +415,10 @@ class SentryCheckInAdviceTest {
@Bean open fun sampleServiceSpringProperties() = SampleServiceSpringProperties()
+ @Bean open fun sampleServiceScheduled() = SampleServiceScheduled()
+
+ @Bean open fun sampleServiceInheritedSchedule() = SampleServiceInheritedSchedule()
+
@Bean
open fun scopes(): IScopes {
val scopes = mock()
@@ -291,6 +469,88 @@ class SentryCheckInAdviceTest {
open fun helloExceptionProperty() = 1
}
+ open class SampleServiceScheduled {
+
+ @SentryCheckIn("cron_zone", upsertMonitorConfig = true)
+ @Scheduled(cron = "0 15 10 * * MON-FRI", zone = "Europe/Vienna")
+ open fun cronWithZone() {}
+
+ @SentryCheckIn("cron", upsertMonitorConfig = true)
+ @Scheduled(cron = "0 0 2 * * *")
+ open fun cron() {}
+
+ @SentryCheckIn("cron_properties", upsertMonitorConfig = true)
+ @Scheduled(cron = "\${my.cron.schedule}", zone = "\${my.cron.zone}")
+ open fun cronFromProperties() {}
+
+ @SentryCheckIn("fixed_rate_hours", upsertMonitorConfig = true)
+ @Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS)
+ open fun fixedRateHours() {}
+
+ @SentryCheckIn("fixed_rate_iso", upsertMonitorConfig = true)
+ @Scheduled(fixedRateString = "PT10M")
+ open fun fixedRateIso() {}
+
+ @SentryCheckIn("fixed_rate_simple", upsertMonitorConfig = true)
+ @Scheduled(fixedRateString = "5m")
+ open fun fixedRateSimpleDuration() {}
+
+ @SentryCheckIn("fixed_delay", upsertMonitorConfig = true)
+ @Scheduled(fixedDelay = 300_000)
+ open fun fixedDelay() {}
+
+ @SentryCheckIn("fixed_delay_iso", upsertMonitorConfig = true)
+ @Scheduled(fixedDelayString = "PT10M")
+ open fun fixedDelayIso() {}
+
+ @SentryCheckIn("disabled_cron", upsertMonitorConfig = true)
+ @Scheduled(cron = "-")
+ open fun disabledCron() {}
+
+ @SentryCheckIn("cron_empty_zone", upsertMonitorConfig = true)
+ @Scheduled(cron = "0 0 3 * * *", zone = "\${my.cron.empty.zone}")
+ open fun cronWithEmptyZone() {}
+
+ @SentryCheckIn("cron_zone_defaults", upsertMonitorConfig = true)
+ @Scheduled(cron = "0 0 4 * * *", zone = "Europe/Vienna")
+ open fun cronWithZoneAndDefaults() {}
+
+ @SentryCheckIn("schedules_container", upsertMonitorConfig = true)
+ @Schedules(Scheduled(cron = "0 0 1 * * *"), Scheduled(cron = "0 0 13 * * *"))
+ open fun schedulesContainer() {}
+
+ @SentryCheckIn("unresolvable_cron", upsertMonitorConfig = true)
+ @Scheduled(cron = "\${my.cron.missing}")
+ open fun unresolvableCron() {}
+
+ @SentryCheckIn("derivation_throws", upsertMonitorConfig = true)
+ @Scheduled(cron = "\${my.cron.exception.property}")
+ open fun derivationThrows() = 1
+
+ @SentryCheckIn("multiple", upsertMonitorConfig = true)
+ @Scheduled(cron = "0 0 1 * * *")
+ @Scheduled(cron = "0 0 13 * * *")
+ open fun multipleSchedules() {}
+
+ @SentryCheckIn("upsert_disabled", upsertMonitorConfig = false)
+ @Scheduled(cron = "0 0 1 * * *")
+ open fun upsertDisabled() {}
+
+ @SentryCheckIn("upsert_default") @Scheduled(cron = "0 0 1 * * *") open fun upsertDefault() {}
+
+ @SentryCheckIn("heartbeat", heartbeat = true, upsertMonitorConfig = true)
+ @Scheduled(cron = "0 0 1 * * *")
+ open fun heartbeat() {}
+ }
+
+ open class ScheduledParent {
+ @Scheduled(cron = "0 0 1 * * *") open fun job() {}
+ }
+
+ open class SampleServiceInheritedSchedule : ScheduledParent() {
+ @SentryCheckIn("inherited", upsertMonitorConfig = true) override fun job() {}
+ }
+
class MyPropertyPlaceholderConfigurer : PropertySourcesPlaceholderConfigurer() {
override fun doProcessProperties(
diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/checkin/SentryScheduledCheckInTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/checkin/SentryScheduledCheckInTest.kt
new file mode 100644
index 00000000000..2e60dbcc556
--- /dev/null
+++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/checkin/SentryScheduledCheckInTest.kt
@@ -0,0 +1,365 @@
+package io.sentry.spring7.checkin
+
+import io.sentry.CheckIn
+import io.sentry.CheckInStatus
+import io.sentry.ILogger
+import io.sentry.IScopes
+import io.sentry.ISentryLifecycleToken
+import io.sentry.SentryLevel
+import io.sentry.SentryOptions
+import io.sentry.protocol.SentryId
+import java.lang.reflect.Modifier
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import org.junit.jupiter.api.assertThrows
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.atLeast
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.reset
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.springframework.aop.Advisor
+import org.springframework.aop.Pointcut
+import org.springframework.aop.framework.ProxyFactory
+import org.springframework.aop.support.AopUtils
+import org.springframework.aop.support.DefaultPointcutAdvisor
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.beans.factory.annotation.Qualifier
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.context.annotation.EnableAspectJAutoProxy
+import org.springframework.context.annotation.Import
+import org.springframework.context.support.PropertySourcesPlaceholderConfigurer
+import org.springframework.scheduling.annotation.Scheduled
+import org.springframework.test.context.TestPropertySource
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig
+import org.springframework.test.context.junit4.SpringRunner
+import reactor.core.publisher.Mono
+
+@RunWith(SpringRunner::class)
+@SpringJUnitConfig(SentryScheduledCheckInTest.Config::class)
+@TestPropertySource(properties = ["my.cron.schedule = 0 30 2 * * *"])
+class SentryScheduledCheckInTest {
+ private val SLUG = "sentry-scheduled-check-in-test-jobs-sync-users"
+ private val COLLISION_WARNING =
+ "Monitor slug \"%s\" is used by both %s and %s, so their check-ins go to one monitor. Add @SentryCheckIn(\"your-slug\") to one of them."
+
+ @Autowired lateinit var jobs: Jobs
+
+ @Autowired lateinit var excludedJobs: ExcludedJobs
+
+ @Autowired lateinit var finalJobs: FinalJobs
+
+ @Autowired lateinit var overridingJobs: OverridingJobs
+
+ @Autowired lateinit var scopes: IScopes
+
+ @BeforeTest
+ fun setup() {
+ reset(scopes)
+ whenever(scopes.options).thenReturn(SentryOptions())
+ whenever(scopes.forkedScopes(any())).thenReturn(scopes)
+ whenever(scopes.makeCurrent()).thenReturn(mock())
+ whenever(scopes.captureCheckIn(any())).thenReturn(SentryId())
+ }
+
+ @Test
+ fun `scheduled method without @SentryCheckIn is monitored with a derived slug and config`() {
+ val checkIns = capture { jobs.syncUsers() }
+ assertEquals(2, checkIns.size)
+ assertEquals(SLUG, checkIns[0].monitorSlug)
+ assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkIns[0].status)
+ val config = assertNotNull(checkIns[0].monitorConfig)
+ assertEquals("0 2 * * *", config.schedule.value)
+ assertEquals("Europe/Vienna", config.timezone)
+ assertEquals(SLUG, checkIns[1].monitorSlug)
+ assertEquals(CheckInStatus.OK.apiName(), checkIns[1].status)
+ }
+
+ @Test
+ fun `cron placeholder is resolved`() {
+ val checkIns = capture { jobs.fromProperty() }
+ assertEquals("30 2 * * *", checkIns[0].monitorConfig!!.schedule.value)
+ }
+
+ @Test
+ fun `tasks with unsupported schedules are not monitored`() {
+ jobs.fixedDelay()
+ verify(scopes, never()).captureCheckIn(any())
+ }
+
+ @Test
+ fun `@SentryCheckIn with an unsupported schedule checks in without config`() {
+ val checkIns = capture { jobs.explicitFixedDelay() }
+ assertEquals(listOf("explicit-delay", "explicit-delay"), checkIns.map { it.monitorSlug })
+ assertNull(checkIns[0].monitorConfig)
+ }
+
+ @Test
+ fun `failing job sends an error check-in and rethrows`() {
+ val checkIns = capture { assertThrows { jobs.oops() } }
+ assertEquals(CheckInStatus.ERROR.apiName(), checkIns[1].status)
+ }
+
+ @Test
+ fun `@SentryCheckIn keeps its slug and is only checked in once`() {
+ val checkIns = capture { jobs.explicit() }
+ assertEquals(listOf("explicit", "explicit"), checkIns.map { it.monitorSlug })
+ assertNull(checkIns[0].monitorConfig)
+ }
+
+ @Test
+ fun `methods without @Scheduled are not monitored`() {
+ jobs.notScheduled()
+ verify(scopes, never()).captureCheckIn(any())
+ }
+
+ @Test
+ fun `overriding methods without @Scheduled are not monitored`() {
+ overridingJobs.run()
+ verify(scopes, never()).captureCheckIn(any())
+ }
+
+ @Test
+ fun `reactive methods are not monitored`() {
+ jobs.reactive().block()
+ verify(scopes, never()).captureCheckIn(any())
+ }
+
+ @Test
+ fun `excluded tasks are not monitored`() {
+ jobs.excluded()
+ excludedJobs.run()
+ verify(scopes, never()).captureCheckIn(any())
+ assertFalse(AopUtils.isAopProxy(excludedJobs))
+ }
+
+ @Test
+ fun `final classes are not proxied`() {
+ finalJobs.run()
+ verify(scopes, never()).captureCheckIn(any())
+ assertFalse(AopUtils.isAopProxy(finalJobs))
+ assertTrue(AopUtils.isAopProxy(jobs))
+ }
+
+ @Test
+ fun `private and static methods are not matched`() {
+ val pointcut = SentryScheduledCheckInPointcut(null)
+ val privateMethod = HiddenJobs::class.java.getDeclaredMethod("hidden")
+ val staticMethod = HiddenJobs::class.java.getDeclaredMethod("static")
+ assertTrue(Modifier.isPrivate(privateMethod.modifiers))
+ assertTrue(Modifier.isStatic(staticMethod.modifiers))
+ assertFalse(pointcut.matches(privateMethod, HiddenJobs::class.java))
+ assertFalse(pointcut.matches(staticMethod, HiddenJobs::class.java))
+ assertTrue(pointcut.matches(Jobs::class.java.getMethod("syncUsers"), Jobs::class.java))
+ }
+
+ @Test
+ fun `slugs are kebab case and at most 50 characters`() {
+ assertEquals(
+ "report-jobs-send-daily-report",
+ SentryCheckInAdvice.scheduledTaskSlug("ReportJobs.sendDailyReport"),
+ )
+ assertEquals(
+ "http-client-jobs-run",
+ SentryCheckInAdvice.scheduledTaskSlug("HTTPClientJobs.run"),
+ )
+ assertEquals("outer-inner-run2-fa", SentryCheckInAdvice.scheduledTaskSlug("Outer.Inner.run2FA"))
+ val first =
+ SentryCheckInAdvice.scheduledTaskSlug(
+ "AVeryLongScheduledJobClassNameForReporting.firstMethod"
+ )
+ val second =
+ SentryCheckInAdvice.scheduledTaskSlug(
+ "AVeryLongScheduledJobClassNameForReporting.secondMethod"
+ )
+ assertTrue(first.length <= 50)
+ assertTrue(first.startsWith("a-very-long-scheduled-job-class-name-for-"))
+ assertTrue(first != second)
+ assertEquals(
+ first,
+ SentryCheckInAdvice.scheduledTaskSlug(
+ "AVeryLongScheduledJobClassNameForReporting.firstMethod"
+ ),
+ )
+ }
+
+ @Test
+ fun `task name uses the user class`() {
+ assertEquals(
+ "SentryScheduledCheckInTest.Jobs.syncUsers",
+ SentryCheckInAdvice.scheduledTaskName(
+ jobs.javaClass,
+ Jobs::class.java.getMethod("syncUsers"),
+ ),
+ )
+ }
+
+ @Test
+ fun `warns once when two methods derive the same slug`() {
+ val logger = mockLogger()
+ val advice = SentryCheckInAdvice(scopes, true)
+ val cleanupJob = proxy(CleanupJob(), advice)
+ val cleanup = proxy(Cleanup(), advice)
+
+ cleanupJob.run()
+ cleanup.jobRun()
+ cleanup.jobRun()
+ cleanupJob.run()
+
+ verify(logger, times(1))
+ .log(
+ eq(SentryLevel.WARNING),
+ eq(COLLISION_WARNING),
+ eq("sentry-scheduled-check-in-test-cleanup-job-run"),
+ eq("${CleanupJob::class.java.name}.run"),
+ eq("${Cleanup::class.java.name}.jobRun"),
+ )
+ }
+
+ @Test
+ fun `does not warn when derived slugs differ or the same method runs again`() {
+ val logger = mockLogger()
+ val advice = SentryCheckInAdvice(scopes, true)
+ val cleanupJob = proxy(CleanupJob(), advice)
+ val otherJobs = proxy(Jobs(), advice)
+
+ cleanupJob.run()
+ cleanupJob.run()
+ otherJobs.syncUsers()
+
+ verify(logger, never()).log(eq(SentryLevel.WARNING), eq(COLLISION_WARNING), any(), any(), any())
+ }
+
+ private fun mockLogger(): ILogger {
+ val logger = mock()
+ val options =
+ SentryOptions().apply {
+ isDebug = true
+ setLogger(logger)
+ }
+ whenever(scopes.options).thenReturn(options)
+ return logger
+ }
+
+ private fun proxy(target: T, advice: SentryCheckInAdvice): T {
+ val factory = ProxyFactory(target)
+ factory.isProxyTargetClass = true
+ factory.addAdvice(advice)
+ @Suppress("UNCHECKED_CAST")
+ return factory.proxy as T
+ }
+
+ private fun capture(block: () -> Unit): List {
+ val captor = argumentCaptor()
+ block()
+ verify(scopes, atLeast(0)).captureCheckIn(captor.capture())
+ return captor.allValues
+ }
+
+ @Configuration
+ @EnableAspectJAutoProxy(proxyTargetClass = true)
+ @Import(SentryCheckInPointcutConfiguration::class)
+ open class Config {
+
+ @Bean open fun jobs() = Jobs()
+
+ @Bean open fun excludedJobs() = ExcludedJobs()
+
+ @Bean open fun finalJobs() = FinalJobs()
+
+ @Bean open fun overridingJobs() = OverridingJobs()
+
+ @Bean open fun scopes(): IScopes = mock()
+
+ @Bean
+ open fun sentryCheckInAdvisor(
+ @Qualifier("sentryCheckInPointcut") pointcut: Pointcut,
+ scopes: IScopes,
+ ): Advisor = DefaultPointcutAdvisor(pointcut, SentryCheckInAdvice(scopes))
+
+ @Bean open fun sentryScheduledCheckInAdvice(scopes: IScopes) = SentryCheckInAdvice(scopes, true)
+
+ @Bean
+ open fun sentryScheduledCheckInAdvisor(
+ @Qualifier("sentryScheduledCheckInAdvice") advice: SentryCheckInAdvice
+ ): Advisor =
+ DefaultPointcutAdvisor(
+ SentryScheduledCheckInPointcut(
+ listOf("SentryScheduledCheckInTest.Jobs.excluded", ".*\\.ExcludedJobs\\..*", " ")
+ ),
+ advice,
+ )
+
+ companion object {
+ @Bean
+ @JvmStatic
+ fun propertySourcesPlaceholderConfigurer() = PropertySourcesPlaceholderConfigurer()
+ }
+ }
+
+ open class Jobs {
+ @Scheduled(cron = "0 0 2 * * *", zone = "Europe/Vienna") open fun syncUsers() {}
+
+ @Scheduled(cron = "\${my.cron.schedule}") open fun fromProperty() {}
+
+ @Scheduled(fixedDelay = 1000) open fun fixedDelay() {}
+
+ @Scheduled(cron = "0 0 2 * * *")
+ open fun oops() {
+ throw IllegalStateException("thrown on purpose")
+ }
+
+ @SentryCheckIn("explicit") @Scheduled(cron = "0 0 2 * * *") open fun explicit() {}
+
+ @SentryCheckIn("explicit-delay") @Scheduled(fixedDelay = 1000) open fun explicitFixedDelay() {}
+
+ @Scheduled(cron = "0 0 2 * * *") open fun excluded() {}
+
+ open fun notScheduled() {}
+
+ @Scheduled(cron = "0 0 2 * * *") open fun reactive(): Mono = Mono.empty()
+ }
+
+ open class CleanupJob {
+ @Scheduled(cron = "0 0 2 * * *") open fun run() {}
+ }
+
+ open class Cleanup {
+ @Scheduled(cron = "0 0 2 * * *") open fun jobRun() {}
+ }
+
+ open class HiddenJobs {
+ @Scheduled(cron = "0 0 2 * * *") private fun hidden() {}
+
+ companion object {
+ @JvmStatic @Scheduled(cron = "0 0 2 * * *") fun static() {}
+ }
+ }
+
+ open class ExcludedJobs {
+ @Scheduled(cron = "0 0 2 * * *") open fun run() {}
+ }
+
+ class FinalJobs {
+ @Scheduled(cron = "0 0 2 * * *") fun run() {}
+ }
+
+ open class ParentJobs {
+ @Scheduled(cron = "0 0 2 * * *") open fun run() {}
+ }
+
+ open class OverridingJobs : ParentJobs() {
+ override fun run() {}
+ }
+}
diff --git a/sentry-spring-boot-4-starter/build.gradle.kts b/sentry-spring-boot-4-starter/build.gradle.kts
index fca672dac9d..c3c5a4d7b0a 100644
--- a/sentry-spring-boot-4-starter/build.gradle.kts
+++ b/sentry-spring-boot-4-starter/build.gradle.kts
@@ -9,6 +9,9 @@ plugins {
alias(libs.plugins.errorprone)
alias(libs.plugins.gradle.versions)
alias(libs.plugins.springboot4) apply false
+ id("io.sentry.spotless")
+ id("io.sentry.jdk-target")
+ id("io.sentry.publish")
id("io.sentry.api-validation")
}
diff --git a/sentry-spring-boot-4/api/sentry-spring-boot-4.api b/sentry-spring-boot-4/api/sentry-spring-boot-4.api
index cd41459c31a..247b12483aa 100644
--- a/sentry-spring-boot-4/api/sentry-spring-boot-4.api
+++ b/sentry-spring-boot-4/api/sentry-spring-boot-4.api
@@ -43,19 +43,23 @@ public class io/sentry/spring/boot4/SentryProperties : io/sentry/SentryOptions {
public fun ()V
public fun getClients ()Lio/sentry/spring/boot4/SentryProperties$Clients;
public fun getExceptionResolverOrder ()I
+ public fun getExcludeScheduledTasks ()Ljava/util/List;
public fun getGraphql ()Lio/sentry/spring/boot4/SentryProperties$Graphql;
public fun getLogging ()Lio/sentry/spring/boot4/SentryProperties$Logging;
public fun getReactive ()Lio/sentry/spring/boot4/SentryProperties$Reactive;
public fun getUserFilterOrder ()Ljava/lang/Integer;
public fun isEnableAotCompatibility ()Z
public fun isKeepTransactionsOpenForAsyncResponses ()Z
+ public fun isMonitorScheduledTasks ()Z
public fun isUseGitCommitIdAsRelease ()Z
public fun setClients (Lio/sentry/spring/boot4/SentryProperties$Clients;)V
public fun setEnableAotCompatibility (Z)V
public fun setExceptionResolverOrder (I)V
+ public fun setExcludeScheduledTasks (Ljava/util/List;)V
public fun setGraphql (Lio/sentry/spring/boot4/SentryProperties$Graphql;)V
public fun setKeepTransactionsOpenForAsyncResponses (Z)V
public fun setLogging (Lio/sentry/spring/boot4/SentryProperties$Logging;)V
+ public fun setMonitorScheduledTasks (Z)V
public fun setReactive (Lio/sentry/spring/boot4/SentryProperties$Reactive;)V
public fun setUseGitCommitIdAsRelease (Z)V
public fun setUserFilterOrder (Ljava/lang/Integer;)V
@@ -70,6 +74,8 @@ public class io/sentry/spring/boot4/SentryProperties$Clients {
public class io/sentry/spring/boot4/SentryProperties$Graphql {
public fun ()V
public fun getIgnoredErrorTypes ()Ljava/util/List;
+ public fun isEnableSpecificationErrorTypeFiltering ()Z
+ public fun setEnableSpecificationErrorTypeFiltering (Z)V
public fun setIgnoredErrorTypes (Ljava/util/List;)V
}
diff --git a/sentry-spring-boot-4/build.gradle.kts b/sentry-spring-boot-4/build.gradle.kts
index 26524b4f81e..5629fdd5aa4 100644
--- a/sentry-spring-boot-4/build.gradle.kts
+++ b/sentry-spring-boot-4/build.gradle.kts
@@ -13,6 +13,9 @@ plugins {
alias(libs.plugins.gradle.versions)
alias(libs.plugins.buildconfig)
alias(libs.plugins.springboot4) apply false
+ id("io.sentry.spotless")
+ id("io.sentry.jdk-target")
+ id("io.sentry.publish")
id("io.sentry.api-validation")
}
@@ -83,7 +86,7 @@ dependencies {
testImplementation(projects.sentryReactor)
testImplementation(projects.sentryTestSupport)
testImplementation(kotlin(Config.kotlinStdLib))
- testImplementation(Config.Libs.kotlinReflect)
+ testImplementation(libs.kotlin.reflect)
testImplementation(platform(SpringBootPlugin.BOM_COORDINATES))
testImplementation(libs.apache.httpclient)
testImplementation(libs.context.propagation)
diff --git a/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/AgentApp.kt b/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/AgentApp.kt
index 1ee54ed9795..24496ee290a 100644
--- a/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/AgentApp.kt
+++ b/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/AgentApp.kt
@@ -41,6 +41,8 @@ object AgentApp {
"SENTRY_PROFILE_LIFECYCLE" to "TRACE",
"SENTRY_PROFILING_TRACES_DIR_PATH" to
"${System.getProperty("java.io.tmpdir")}/sentry-integration-test/profiling-traces",
+ // Tests wait for Sentry's span batches, which are 5 seconds apart by default.
+ "OTEL_BSP_SCHEDULE_DELAY" to "100ms",
"OTEL_TRACES_EXPORTER" to "none",
"OTEL_METRICS_EXPORTER" to "none",
"OTEL_LOGS_EXPORTER" to "none",
diff --git a/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/DistributedTracingIntegrationTest.kt
index 353e6d62fd8..ad7025146b6 100644
--- a/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/DistributedTracingIntegrationTest.kt
+++ b/sentry-spring-boot-4/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot4/otelagentintegrationtest/DistributedTracingIntegrationTest.kt
@@ -47,8 +47,8 @@ class DistributedTracingIntegrationTest : IntegrationTest() {
)
assertThat(status).isEqualTo(500)
- // Longer than the 5 seconds the app's spans can wait for their batch to be exported.
- server.assertNoEnvelope(millis = 6_000) { isForRequest(it, id) }
+ // Ten times the app's batch delay, so the request's spans would have been exported.
+ server.assertNoEnvelope(millis = 1_000) { isForRequest(it, id) }
}
@Test
diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java
index 0cfcb5e52a7..4c14b0ea858 100644
--- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java
+++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryAutoConfiguration.java
@@ -26,9 +26,11 @@
import io.sentry.spring7.SpringProfilesEventProcessor;
import io.sentry.spring7.SpringSecuritySentryUserProvider;
import io.sentry.spring7.cache.SentryCacheBeanPostProcessor;
+import io.sentry.spring7.checkin.SentryCheckInAdvice;
import io.sentry.spring7.checkin.SentryCheckInAdviceConfiguration;
import io.sentry.spring7.checkin.SentryCheckInPointcutConfiguration;
import io.sentry.spring7.checkin.SentryQuartzConfiguration;
+import io.sentry.spring7.checkin.SentryScheduledCheckInPointcut;
import io.sentry.spring7.exception.SentryCaptureExceptionParameterPointcutConfiguration;
import io.sentry.spring7.exception.SentryExceptionParameterAdviceConfiguration;
import io.sentry.spring7.kafka.SentryKafkaConsumerBeanPostProcessor;
@@ -48,11 +50,16 @@
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
+import org.aopalliance.aop.Advice;
import org.aspectj.lang.ProceedingJoinPoint;
import org.jetbrains.annotations.NotNull;
import org.quartz.core.QuartzScheduler;
import org.slf4j.MDC;
+import org.springframework.aop.Advisor;
+import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.condition.AnyNestedCondition;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@@ -72,6 +79,7 @@
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Role;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment;
@@ -306,6 +314,27 @@ static class SentryCheckInAspectsConfiguration {
@Import(SentryCheckInPointcutConfiguration.class)
@Open
static class SentryCheckInPointcutAutoConfiguration {}
+
+ @Configuration(proxyBeanMethods = false)
+ @ConditionalOnProperty(name = "sentry.monitor-scheduled-tasks", havingValue = "true")
+ @Open
+ static class SentryScheduledCheckInConfiguration {
+ @Bean
+ @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
+ public @NotNull Advice sentryScheduledCheckInAdvice() {
+ return new SentryCheckInAdvice(ScopesAdapter.getInstance(), true);
+ }
+
+ @Bean
+ @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
+ public @NotNull Advisor sentryScheduledCheckInAdvisor(
+ final @NotNull @Qualifier("sentryScheduledCheckInAdvice") Advice advice,
+ final @NotNull SentryProperties sentryProperties) {
+ return new DefaultPointcutAdvisor(
+ new SentryScheduledCheckInPointcut(sentryProperties.getExcludeScheduledTasks()),
+ advice);
+ }
+ }
}
/** Registers beans specific to Spring MVC. */
diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryProperties.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryProperties.java
index 6c502976b15..b3dffa198e5 100644
--- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryProperties.java
+++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/SentryProperties.java
@@ -50,6 +50,15 @@ public class SentryProperties extends SentryOptions {
/** Clients integration properties. */
private @NotNull Clients clients = new Clients();
+ /** Monitor every {@code @Scheduled} method with Sentry Crons. */
+ private boolean monitorScheduledTasks = false;
+
+ /**
+ * {@code @Scheduled} methods not to monitor when {@code monitorScheduledTasks} is on, as {@code
+ * ClassName.methodName} or a regex matching it.
+ */
+ private @NotNull List excludeScheduledTasks = new ArrayList<>();
+
public boolean isUseGitCommitIdAsRelease() {
return useGitCommitIdAsRelease;
}
@@ -135,6 +144,22 @@ public void setClients(@NotNull Clients clients) {
this.clients = clients;
}
+ public boolean isMonitorScheduledTasks() {
+ return monitorScheduledTasks;
+ }
+
+ public void setMonitorScheduledTasks(boolean monitorScheduledTasks) {
+ this.monitorScheduledTasks = monitorScheduledTasks;
+ }
+
+ public @NotNull List getExcludeScheduledTasks() {
+ return excludeScheduledTasks;
+ }
+
+ public void setExcludeScheduledTasks(@NotNull List excludeScheduledTasks) {
+ this.excludeScheduledTasks = excludeScheduledTasks;
+ }
+
@Open
public static class Logging {
/** Enable/Disable logging auto-configuration. */
@@ -228,6 +253,9 @@ public static class Graphql {
/** List of error types the Sentry Graphql integration should ignore. */
private @NotNull List ignoredErrorTypes = new ArrayList<>();
+ /** Whether ignored error types should be matched against classification specifications. */
+ private boolean enableSpecificationErrorTypeFiltering = false;
+
@NotNull
public List getIgnoredErrorTypes() {
return ignoredErrorTypes;
@@ -236,6 +264,15 @@ public List getIgnoredErrorTypes() {
public void setIgnoredErrorTypes(final @NotNull List ignoredErrorTypes) {
this.ignoredErrorTypes = ignoredErrorTypes;
}
+
+ public boolean isEnableSpecificationErrorTypeFiltering() {
+ return enableSpecificationErrorTypeFiltering;
+ }
+
+ public void setEnableSpecificationErrorTypeFiltering(
+ final boolean enableSpecificationErrorTypeFiltering) {
+ this.enableSpecificationErrorTypeFiltering = enableSpecificationErrorTypeFiltering;
+ }
}
@Open
diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphql22AutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphql22AutoConfiguration.java
index e83b6241847..f1a9c74ebb0 100644
--- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphql22AutoConfiguration.java
+++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphql22AutoConfiguration.java
@@ -56,7 +56,8 @@ private SentryInstrumentation createInstrumentation(
beforeSpanCallback.getIfAvailable(),
new SentrySpringSubscriptionHandler(),
captureRequestBody,
- sentryProperties.getGraphql().getIgnoredErrorTypes());
+ sentryProperties.getGraphql().getIgnoredErrorTypes(),
+ sentryProperties.getGraphql().isEnableSpecificationErrorTypeFiltering());
}
@Bean
diff --git a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphqlAutoConfiguration.java b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphqlAutoConfiguration.java
index 7bcef3c7b4d..7565b48a25c 100644
--- a/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphqlAutoConfiguration.java
+++ b/sentry-spring-boot-4/src/main/java/io/sentry/spring/boot4/graphql/SentryGraphqlAutoConfiguration.java
@@ -56,7 +56,8 @@ private SentryInstrumentation createInstrumentation(
beforeSpanCallback.getIfAvailable(),
new SentrySpringSubscriptionHandler(),
captureRequestBody,
- sentryProperties.getGraphql().getIgnoredErrorTypes());
+ sentryProperties.getGraphql().getIgnoredErrorTypes(),
+ sentryProperties.getGraphql().isEnableSpecificationErrorTypeFiltering());
}
@Bean
diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt
index 7f15669b104..7360e732da7 100644
--- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt
+++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryAutoConfigurationTest.kt
@@ -16,6 +16,7 @@ import io.sentry.NoOpProfileConverter
import io.sentry.NoOpTransportFactory
import io.sentry.SamplingContext
import io.sentry.Sentry
+import io.sentry.SentryEnvelope
import io.sentry.SentryEvent
import io.sentry.SentryIntegrationPackageStorage
import io.sentry.SentryLevel
@@ -45,6 +46,7 @@ import io.sentry.transport.ITransport
import io.sentry.transport.ITransportGate
import jakarta.servlet.Filter
import java.lang.RuntimeException
+import java.util.concurrent.TimeUnit
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -52,9 +54,12 @@ import kotlin.test.assertTrue
import org.aspectj.lang.ProceedingJoinPoint
import org.assertj.core.api.Assertions.assertThat
import org.mockito.internal.util.MockUtil.isMock
+import org.mockito.kotlin.after
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.mock
+import org.mockito.kotlin.timeout
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.quartz.JobExecutionContext
@@ -65,6 +70,7 @@ import org.quartz.core.QuartzScheduler
import org.slf4j.MDC
import org.springframework.aop.support.NameMatchMethodPointcut
import org.springframework.boot.autoconfigure.AutoConfigurations
+import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration
import org.springframework.boot.context.annotation.UserConfigurations
import org.springframework.boot.info.GitProperties
import org.springframework.boot.quartz.autoconfigure.QuartzAutoConfiguration
@@ -79,6 +85,8 @@ import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
+import org.springframework.scheduling.annotation.EnableScheduling
+import org.springframework.scheduling.annotation.Scheduled
import org.springframework.scheduling.quartz.SchedulerFactoryBean
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.client.RestClient
@@ -192,6 +200,33 @@ class SentryAutoConfigurationTest {
}
}
+ @Test
+ fun `GraphQL specification error type filtering is disabled by default`() {
+ dsnEnabledRunner.run {
+ assertThat(
+ it.getBean(SentryProperties::class.java).graphql.isEnableSpecificationErrorTypeFiltering
+ )
+ .isFalse()
+ }
+ }
+
+ @Test
+ fun `GraphQL specification error type filtering property is applied`() {
+ listOf(false, true).forEach { enabled ->
+ dsnEnabledRunner
+ .withPropertyValues("sentry.graphql.enable-specification-error-type-filtering=$enabled")
+ .run {
+ assertThat(
+ it
+ .getBean(SentryProperties::class.java)
+ .graphql
+ .isEnableSpecificationErrorTypeFiltering
+ )
+ .isEqualTo(enabled)
+ }
+ }
+ }
+
@Test
fun `properties are applied to SentryOptions`() {
contextRunner
@@ -1033,6 +1068,44 @@ class SentryAutoConfigurationTest {
}
}
+ @Test
+ fun `does not monitor @Scheduled methods by default`() {
+ contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run {
+ assertThat(it).doesNotHaveBean("sentryScheduledCheckInAdvisor")
+ }
+ }
+
+ @Test
+ fun `monitors @Scheduled methods when monitor-scheduled-tasks is enabled`() {
+ baseContextRunner
+ .withPropertyValues(
+ "sentry.dsn=http://key@localhost/proj",
+ "sentry.monitor-scheduled-tasks=true",
+ "sentry.exclude-scheduled-tasks[0]=SentryAutoConfigurationTest.ScheduledJobs.excluded",
+ )
+ .withConfiguration(AutoConfigurations.of(AopAutoConfiguration::class.java))
+ .withUserConfiguration(
+ MockTransportConfiguration::class.java,
+ ScheduledJobsConfiguration::class.java,
+ )
+ .run {
+ assertThat(it).hasBean("sentryScheduledCheckInAdvisor")
+ val transport = it.getBean(ITransport::class.java)
+ val captor = argumentCaptor()
+ verify(transport, timeout(5000).times(2)).send(any(), anyOrNull())
+ verify(transport, after(500).times(2)).send(captor.capture(), anyOrNull())
+ val payloads =
+ captor.allValues.map { envelope -> String(envelope.items.first().data, Charsets.UTF_8) }
+ assertThat(payloads).allMatch { payload ->
+ payload.contains(
+ "\"monitor_slug\":\"sentry-auto-configuration-test-scheduled-jobs-sync\""
+ )
+ }
+ assertThat(payloads[0]).contains("\"value\":1,\"unit\":\"hour\"")
+ assertThat(payloads[1]).contains("\"status\":\"ok\"")
+ }
+ }
+
@Test
fun `Sentry quartz job listener is added`() {
contextRunner
@@ -1279,6 +1352,18 @@ class SentryAutoConfigurationTest {
@Bean open fun sentryTransport() = transport
}
+ @Configuration(proxyBeanMethods = false)
+ @EnableScheduling
+ open class ScheduledJobsConfiguration {
+ @Bean open fun scheduledJobs() = ScheduledJobs()
+ }
+
+ open class ScheduledJobs {
+ @Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS) open fun sync() {}
+
+ @Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS) open fun excluded() {}
+ }
+
@Configuration(proxyBeanMethods = false)
open class NoOpTransportConfiguration {
diff --git a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt
index b5ead41901d..8ff176c7755 100644
--- a/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt
+++ b/sentry-spring-boot-4/src/test/kotlin/io/sentry/spring/boot4/SentryLog4j2AppenderAutoConfigurationTest.kt
@@ -89,8 +89,9 @@ class SentryLog4j2AppenderAutoConfigurationTest {
private val originalLogManagerFactory = LogManager.getFactory()
- private val loggerContext: LoggerContext
- get() = LogManager.getContext(false) as LoggerContext
+ // Log4j2 holds LoggerContexts weakly, so a GC during context startup would replace this one
+ // (and drop appenders added by a test) unless the test keeps it reachable.
+ private lateinit var loggerContext: LoggerContext
private val configuration
get() = loggerContext.configuration
@@ -102,6 +103,7 @@ class SentryLog4j2AppenderAutoConfigurationTest {
fun `reset Log4j2 context`() {
useLog4j2Core()
resetLog4j2Context()
+ loggerContext = LogManager.getContext(false) as LoggerContext
}
@AfterTest
diff --git a/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api b/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api
index 88c87172928..c9505f9966c 100644
--- a/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api
+++ b/sentry-spring-boot-jakarta/api/sentry-spring-boot-jakarta.api
@@ -43,19 +43,23 @@ public class io/sentry/spring/boot/jakarta/SentryProperties : io/sentry/SentryOp
public fun ()V
public fun getClients ()Lio/sentry/spring/boot/jakarta/SentryProperties$Clients;
public fun getExceptionResolverOrder ()I
+ public fun getExcludeScheduledTasks ()Ljava/util/List;
public fun getGraphql ()Lio/sentry/spring/boot/jakarta/SentryProperties$Graphql;
public fun getLogging ()Lio/sentry/spring/boot/jakarta/SentryProperties$Logging;
public fun getReactive ()Lio/sentry/spring/boot/jakarta/SentryProperties$Reactive;
public fun getUserFilterOrder ()Ljava/lang/Integer;
public fun isEnableAotCompatibility ()Z
public fun isKeepTransactionsOpenForAsyncResponses ()Z
+ public fun isMonitorScheduledTasks ()Z
public fun isUseGitCommitIdAsRelease ()Z
public fun setClients (Lio/sentry/spring/boot/jakarta/SentryProperties$Clients;)V
public fun setEnableAotCompatibility (Z)V
public fun setExceptionResolverOrder (I)V
+ public fun setExcludeScheduledTasks (Ljava/util/List;)V
public fun setGraphql (Lio/sentry/spring/boot/jakarta/SentryProperties$Graphql;)V
public fun setKeepTransactionsOpenForAsyncResponses (Z)V
public fun setLogging (Lio/sentry/spring/boot/jakarta/SentryProperties$Logging;)V
+ public fun setMonitorScheduledTasks (Z)V
public fun setReactive (Lio/sentry/spring/boot/jakarta/SentryProperties$Reactive;)V
public fun setUseGitCommitIdAsRelease (Z)V
public fun setUserFilterOrder (Ljava/lang/Integer;)V
@@ -70,6 +74,8 @@ public class io/sentry/spring/boot/jakarta/SentryProperties$Clients {
public class io/sentry/spring/boot/jakarta/SentryProperties$Graphql {
public fun ()V
public fun getIgnoredErrorTypes ()Ljava/util/List;
+ public fun isEnableSpecificationErrorTypeFiltering ()Z
+ public fun setEnableSpecificationErrorTypeFiltering (Z)V
public fun setIgnoredErrorTypes (Ljava/util/List;)V
}
diff --git a/sentry-spring-boot-jakarta/build.gradle.kts b/sentry-spring-boot-jakarta/build.gradle.kts
index 4fd584e8bb5..b678bbd709a 100644
--- a/sentry-spring-boot-jakarta/build.gradle.kts
+++ b/sentry-spring-boot-jakarta/build.gradle.kts
@@ -12,6 +12,9 @@ plugins {
alias(libs.plugins.gradle.versions)
alias(libs.plugins.buildconfig)
alias(libs.plugins.springboot3) apply false
+ id("io.sentry.spotless")
+ id("io.sentry.jdk-target")
+ id("io.sentry.publish")
id("io.sentry.api-validation")
}
diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/AgentApp.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/AgentApp.kt
index c70f8df301a..6864d4d55c9 100644
--- a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/AgentApp.kt
+++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/AgentApp.kt
@@ -41,6 +41,8 @@ object AgentApp {
"SENTRY_PROFILE_LIFECYCLE" to "TRACE",
"SENTRY_PROFILING_TRACES_DIR_PATH" to
"${System.getProperty("java.io.tmpdir")}/sentry-integration-test/profiling-traces",
+ // Tests wait for Sentry's span batches, which are 5 seconds apart by default.
+ "OTEL_BSP_SCHEDULE_DELAY" to "100ms",
"OTEL_TRACES_EXPORTER" to "none",
"OTEL_METRICS_EXPORTER" to "none",
"OTEL_LOGS_EXPORTER" to "none",
diff --git a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/DistributedTracingIntegrationTest.kt
index e83dc383dc3..309fa9b820b 100644
--- a/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/DistributedTracingIntegrationTest.kt
+++ b/sentry-spring-boot-jakarta/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/jakarta/otelagentintegrationtest/DistributedTracingIntegrationTest.kt
@@ -47,8 +47,8 @@ class DistributedTracingIntegrationTest : IntegrationTest() {
)
assertThat(status).isEqualTo(500)
- // Longer than the 5 seconds the app's spans can wait for their batch to be exported.
- server.assertNoEnvelope(millis = 6_000) { isForRequest(it, id) }
+ // Ten times the app's batch delay, so the request's spans would have been exported.
+ server.assertNoEnvelope(millis = 1_000) { isForRequest(it, id) }
}
@Test
diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java
index 7a947a6ad55..ea1ed76c31d 100644
--- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java
+++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryAutoConfiguration.java
@@ -26,9 +26,11 @@
import io.sentry.spring.jakarta.SpringProfilesEventProcessor;
import io.sentry.spring.jakarta.SpringSecuritySentryUserProvider;
import io.sentry.spring.jakarta.cache.SentryCacheBeanPostProcessor;
+import io.sentry.spring.jakarta.checkin.SentryCheckInAdvice;
import io.sentry.spring.jakarta.checkin.SentryCheckInAdviceConfiguration;
import io.sentry.spring.jakarta.checkin.SentryCheckInPointcutConfiguration;
import io.sentry.spring.jakarta.checkin.SentryQuartzConfiguration;
+import io.sentry.spring.jakarta.checkin.SentryScheduledCheckInPointcut;
import io.sentry.spring.jakarta.exception.SentryCaptureExceptionParameterPointcutConfiguration;
import io.sentry.spring.jakarta.exception.SentryExceptionParameterAdviceConfiguration;
import io.sentry.spring.jakarta.kafka.SentryKafkaConsumerBeanPostProcessor;
@@ -48,11 +50,16 @@
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
+import org.aopalliance.aop.Advice;
import org.aspectj.lang.ProceedingJoinPoint;
import org.jetbrains.annotations.NotNull;
import org.quartz.core.QuartzScheduler;
import org.slf4j.MDC;
+import org.springframework.aop.Advisor;
+import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.condition.AnyNestedCondition;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@@ -72,6 +79,7 @@
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Role;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment;
@@ -308,6 +316,27 @@ static class SentryCheckInAspectsConfiguration {
@Import(SentryCheckInPointcutConfiguration.class)
@Open
static class SentryCheckInPointcutAutoConfiguration {}
+
+ @Configuration(proxyBeanMethods = false)
+ @ConditionalOnProperty(name = "sentry.monitor-scheduled-tasks", havingValue = "true")
+ @Open
+ static class SentryScheduledCheckInConfiguration {
+ @Bean
+ @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
+ public @NotNull Advice sentryScheduledCheckInAdvice() {
+ return new SentryCheckInAdvice(ScopesAdapter.getInstance(), true);
+ }
+
+ @Bean
+ @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
+ public @NotNull Advisor sentryScheduledCheckInAdvisor(
+ final @NotNull @Qualifier("sentryScheduledCheckInAdvice") Advice advice,
+ final @NotNull SentryProperties sentryProperties) {
+ return new DefaultPointcutAdvisor(
+ new SentryScheduledCheckInPointcut(sentryProperties.getExcludeScheduledTasks()),
+ advice);
+ }
+ }
}
/** Registers beans specific to Spring MVC. */
diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java
index 53e9226f21f..b8cef42071a 100644
--- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java
+++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/SentryProperties.java
@@ -50,6 +50,15 @@ public class SentryProperties extends SentryOptions {
/** Clients integration properties. */
private @NotNull Clients clients = new Clients();
+ /** Monitor every {@code @Scheduled} method with Sentry Crons. */
+ private boolean monitorScheduledTasks = false;
+
+ /**
+ * {@code @Scheduled} methods not to monitor when {@code monitorScheduledTasks} is on, as {@code
+ * ClassName.methodName} or a regex matching it.
+ */
+ private @NotNull List excludeScheduledTasks = new ArrayList<>();
+
public boolean isUseGitCommitIdAsRelease() {
return useGitCommitIdAsRelease;
}
@@ -135,6 +144,22 @@ public void setClients(@NotNull Clients clients) {
this.clients = clients;
}
+ public boolean isMonitorScheduledTasks() {
+ return monitorScheduledTasks;
+ }
+
+ public void setMonitorScheduledTasks(boolean monitorScheduledTasks) {
+ this.monitorScheduledTasks = monitorScheduledTasks;
+ }
+
+ public @NotNull List getExcludeScheduledTasks() {
+ return excludeScheduledTasks;
+ }
+
+ public void setExcludeScheduledTasks(@NotNull List excludeScheduledTasks) {
+ this.excludeScheduledTasks = excludeScheduledTasks;
+ }
+
@Open
public static class Clients {
/** Enable automatic instrumentation of Spring-managed OkHttp clients. Disabled by default. */
@@ -242,6 +267,9 @@ public static class Graphql {
/** List of error types the Sentry Graphql integration should ignore. */
private @NotNull List ignoredErrorTypes = new ArrayList<>();
+ /** Whether ignored error types should be matched against classification specifications. */
+ private boolean enableSpecificationErrorTypeFiltering = false;
+
@NotNull
public List getIgnoredErrorTypes() {
return ignoredErrorTypes;
@@ -250,5 +278,14 @@ public List getIgnoredErrorTypes() {
public void setIgnoredErrorTypes(final @NotNull List ignoredErrorTypes) {
this.ignoredErrorTypes = ignoredErrorTypes;
}
+
+ public boolean isEnableSpecificationErrorTypeFiltering() {
+ return enableSpecificationErrorTypeFiltering;
+ }
+
+ public void setEnableSpecificationErrorTypeFiltering(
+ final boolean enableSpecificationErrorTypeFiltering) {
+ this.enableSpecificationErrorTypeFiltering = enableSpecificationErrorTypeFiltering;
+ }
}
}
diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphql22AutoConfiguration.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphql22AutoConfiguration.java
index aa741892848..defbc59f5ae 100644
--- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphql22AutoConfiguration.java
+++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphql22AutoConfiguration.java
@@ -56,7 +56,8 @@ private SentryInstrumentation createInstrumentation(
beforeSpanCallback.getIfAvailable(),
new SentrySpringSubscriptionHandler(),
captureRequestBody,
- sentryProperties.getGraphql().getIgnoredErrorTypes());
+ sentryProperties.getGraphql().getIgnoredErrorTypes(),
+ sentryProperties.getGraphql().isEnableSpecificationErrorTypeFiltering());
}
@Bean
diff --git a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphqlAutoConfiguration.java b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphqlAutoConfiguration.java
index e5c1da2df76..7c6c14f8d3f 100644
--- a/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphqlAutoConfiguration.java
+++ b/sentry-spring-boot-jakarta/src/main/java/io/sentry/spring/boot/jakarta/graphql/SentryGraphqlAutoConfiguration.java
@@ -56,7 +56,8 @@ private SentryInstrumentation createInstrumentation(
beforeSpanCallback.getIfAvailable(),
new SentrySpringSubscriptionHandler(),
captureRequestBody,
- sentryProperties.getGraphql().getIgnoredErrorTypes());
+ sentryProperties.getGraphql().getIgnoredErrorTypes(),
+ sentryProperties.getGraphql().isEnableSpecificationErrorTypeFiltering());
}
@Bean
diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt
index 1b46809b3f3..fc2dd676ec3 100644
--- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt
+++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryAutoConfigurationTest.kt
@@ -18,6 +18,7 @@ import io.sentry.NoOpTransportFactory
import io.sentry.ProfileLifecycle
import io.sentry.SamplingContext
import io.sentry.Sentry
+import io.sentry.SentryEnvelope
import io.sentry.SentryEvent
import io.sentry.SentryIntegrationPackageStorage
import io.sentry.SentryLevel
@@ -48,6 +49,7 @@ import io.sentry.transport.ITransportGate
import jakarta.servlet.Filter
import java.io.File
import java.lang.RuntimeException
+import java.util.concurrent.TimeUnit
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -55,9 +57,12 @@ import kotlin.test.assertTrue
import org.aspectj.lang.ProceedingJoinPoint
import org.assertj.core.api.Assertions.assertThat
import org.mockito.internal.util.MockUtil.isMock
+import org.mockito.kotlin.after
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.mock
+import org.mockito.kotlin.timeout
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.quartz.JobExecutionContext
@@ -68,6 +73,7 @@ import org.quartz.core.QuartzScheduler
import org.slf4j.MDC
import org.springframework.aop.support.NameMatchMethodPointcut
import org.springframework.boot.autoconfigure.AutoConfigurations
+import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration
import org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration
import org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
@@ -82,6 +88,8 @@ import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
+import org.springframework.scheduling.annotation.EnableScheduling
+import org.springframework.scheduling.annotation.Scheduled
import org.springframework.scheduling.quartz.SchedulerFactoryBean
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.client.RestClient
@@ -195,6 +203,33 @@ class SentryAutoConfigurationTest {
}
}
+ @Test
+ fun `GraphQL specification error type filtering is disabled by default`() {
+ dsnEnabledRunner.run {
+ assertThat(
+ it.getBean(SentryProperties::class.java).graphql.isEnableSpecificationErrorTypeFiltering
+ )
+ .isFalse()
+ }
+ }
+
+ @Test
+ fun `GraphQL specification error type filtering property is applied`() {
+ listOf(false, true).forEach { enabled ->
+ dsnEnabledRunner
+ .withPropertyValues("sentry.graphql.enable-specification-error-type-filtering=$enabled")
+ .run {
+ assertThat(
+ it
+ .getBean(SentryProperties::class.java)
+ .graphql
+ .isEnableSpecificationErrorTypeFiltering
+ )
+ .isEqualTo(enabled)
+ }
+ }
+ }
+
@Test
fun `properties are applied to SentryOptions`() {
contextRunner
@@ -1052,6 +1087,44 @@ class SentryAutoConfigurationTest {
}
}
+ @Test
+ fun `does not monitor @Scheduled methods by default`() {
+ contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run {
+ assertThat(it).doesNotHaveBean("sentryScheduledCheckInAdvisor")
+ }
+ }
+
+ @Test
+ fun `monitors @Scheduled methods when monitor-scheduled-tasks is enabled`() {
+ baseContextRunner
+ .withPropertyValues(
+ "sentry.dsn=http://key@localhost/proj",
+ "sentry.monitor-scheduled-tasks=true",
+ "sentry.exclude-scheduled-tasks[0]=SentryAutoConfigurationTest.ScheduledJobs.excluded",
+ )
+ .withConfiguration(AutoConfigurations.of(AopAutoConfiguration::class.java))
+ .withUserConfiguration(
+ MockTransportConfiguration::class.java,
+ ScheduledJobsConfiguration::class.java,
+ )
+ .run {
+ assertThat(it).hasBean("sentryScheduledCheckInAdvisor")
+ val transport = it.getBean(ITransport::class.java)
+ val captor = argumentCaptor()
+ verify(transport, timeout(5000).times(2)).send(any(), anyOrNull())
+ verify(transport, after(500).times(2)).send(captor.capture(), anyOrNull())
+ val payloads =
+ captor.allValues.map { envelope -> String(envelope.items.first().data, Charsets.UTF_8) }
+ assertThat(payloads).allMatch { payload ->
+ payload.contains(
+ "\"monitor_slug\":\"sentry-auto-configuration-test-scheduled-jobs-sync\""
+ )
+ }
+ assertThat(payloads[0]).contains("\"value\":1,\"unit\":\"hour\"")
+ assertThat(payloads[1]).contains("\"status\":\"ok\"")
+ }
+ }
+
@Test
fun `Sentry quartz job listener is added`() {
contextRunner
@@ -1271,6 +1344,18 @@ class SentryAutoConfigurationTest {
@Bean open fun sentryTransport() = transport
}
+ @Configuration(proxyBeanMethods = false)
+ @EnableScheduling
+ open class ScheduledJobsConfiguration {
+ @Bean open fun scheduledJobs() = ScheduledJobs()
+ }
+
+ open class ScheduledJobs {
+ @Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS) open fun sync() {}
+
+ @Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS) open fun excluded() {}
+ }
+
@Configuration(proxyBeanMethods = false)
open class NoOpTransportConfiguration {
diff --git a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfigurationTest.kt b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfigurationTest.kt
index f9a0c1f24c0..4523b09e5c2 100644
--- a/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfigurationTest.kt
+++ b/sentry-spring-boot-jakarta/src/test/kotlin/io/sentry/spring/boot/jakarta/SentryLog4j2AppenderAutoConfigurationTest.kt
@@ -89,8 +89,9 @@ class SentryLog4j2AppenderAutoConfigurationTest {
private val originalLogManagerFactory = LogManager.getFactory()
- private val loggerContext: LoggerContext
- get() = LogManager.getContext(false) as LoggerContext
+ // Log4j2 holds LoggerContexts weakly, so a GC during context startup would replace this one
+ // (and drop appenders added by a test) unless the test keeps it reachable.
+ private lateinit var loggerContext: LoggerContext
private val configuration
get() = loggerContext.configuration
@@ -102,6 +103,7 @@ class SentryLog4j2AppenderAutoConfigurationTest {
fun `reset Log4j2 context`() {
useLog4j2Core()
resetLog4j2Context()
+ loggerContext = LogManager.getContext(false) as LoggerContext
}
@AfterTest
diff --git a/sentry-spring-boot-starter-jakarta/build.gradle.kts b/sentry-spring-boot-starter-jakarta/build.gradle.kts
index 92cc97b240c..61291a611d4 100644
--- a/sentry-spring-boot-starter-jakarta/build.gradle.kts
+++ b/sentry-spring-boot-starter-jakarta/build.gradle.kts
@@ -9,6 +9,9 @@ plugins {
alias(libs.plugins.errorprone)
alias(libs.plugins.gradle.versions)
alias(libs.plugins.springboot3) apply false
+ id("io.sentry.spotless")
+ id("io.sentry.jdk-target")
+ id("io.sentry.publish")
id("io.sentry.api-validation")
}
diff --git a/sentry-spring-boot-starter/build.gradle.kts b/sentry-spring-boot-starter/build.gradle.kts
index c78113f363d..5d1228ab0b4 100644
--- a/sentry-spring-boot-starter/build.gradle.kts
+++ b/sentry-spring-boot-starter/build.gradle.kts
@@ -7,6 +7,9 @@ plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.errorprone)
alias(libs.plugins.gradle.versions)
+ id("io.sentry.spotless")
+ id("io.sentry.jdk-target")
+ id("io.sentry.publish")
id("io.sentry.api-validation")
}
diff --git a/sentry-spring-boot/api/sentry-spring-boot.api b/sentry-spring-boot/api/sentry-spring-boot.api
index d4fdcf23e62..e85e0f5d3ff 100644
--- a/sentry-spring-boot/api/sentry-spring-boot.api
+++ b/sentry-spring-boot/api/sentry-spring-boot.api
@@ -32,16 +32,20 @@ public class io/sentry/spring/boot/SentryProperties : io/sentry/SentryOptions {
public fun ()V
public fun getClients ()Lio/sentry/spring/boot/SentryProperties$Clients;
public fun getExceptionResolverOrder ()I
+ public fun getExcludeScheduledTasks ()Ljava/util/List;
public fun getGraphql ()Lio/sentry/spring/boot/SentryProperties$Graphql;
public fun getLogging ()Lio/sentry/spring/boot/SentryProperties$Logging;
public fun getUserFilterOrder ()Ljava/lang/Integer;
public fun isKeepTransactionsOpenForAsyncResponses ()Z
+ public fun isMonitorScheduledTasks ()Z
public fun isUseGitCommitIdAsRelease ()Z
public fun setClients (Lio/sentry/spring/boot/SentryProperties$Clients;)V
public fun setExceptionResolverOrder (I)V
+ public fun setExcludeScheduledTasks (Ljava/util/List;)V
public fun setGraphql (Lio/sentry/spring/boot/SentryProperties$Graphql;)V
public fun setKeepTransactionsOpenForAsyncResponses (Z)V
public fun setLogging (Lio/sentry/spring/boot/SentryProperties$Logging;)V
+ public fun setMonitorScheduledTasks (Z)V
public fun setUseGitCommitIdAsRelease (Z)V
public fun setUserFilterOrder (Ljava/lang/Integer;)V
}
@@ -55,6 +59,8 @@ public class io/sentry/spring/boot/SentryProperties$Clients {
public class io/sentry/spring/boot/SentryProperties$Graphql {
public fun ()V
public fun getIgnoredErrorTypes ()Ljava/util/List;
+ public fun isEnableSpecificationErrorTypeFiltering ()Z
+ public fun setEnableSpecificationErrorTypeFiltering (Z)V
public fun setIgnoredErrorTypes (Ljava/util/List;)V
}
diff --git a/sentry-spring-boot/build.gradle.kts b/sentry-spring-boot/build.gradle.kts
index 02a7b508225..b05690fa905 100644
--- a/sentry-spring-boot/build.gradle.kts
+++ b/sentry-spring-boot/build.gradle.kts
@@ -11,6 +11,9 @@ plugins {
alias(libs.plugins.errorprone)
alias(libs.plugins.gradle.versions)
alias(libs.plugins.buildconfig)
+ id("io.sentry.spotless")
+ id("io.sentry.jdk-target")
+ id("io.sentry.publish")
id("io.sentry.api-validation")
}
diff --git a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/AgentApp.kt b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/AgentApp.kt
index a6bd0bb35ff..32dd96a86d5 100644
--- a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/AgentApp.kt
+++ b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/AgentApp.kt
@@ -34,6 +34,8 @@ class AgentApp(args: List = emptyList()) : Closeable {
"SENTRY_PROFILE_LIFECYCLE" to "TRACE",
"SENTRY_PROFILING_TRACES_DIR_PATH" to
"${System.getProperty("java.io.tmpdir")}/sentry-integration-test/profiling-traces",
+ // Tests wait for Sentry's span batches, which are 5 seconds apart by default.
+ "OTEL_BSP_SCHEDULE_DELAY" to "100ms",
"OTEL_TRACES_EXPORTER" to "none",
"OTEL_METRICS_EXPORTER" to "none",
"OTEL_LOGS_EXPORTER" to "none",
diff --git a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/DistributedTracingIntegrationTest.kt b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/DistributedTracingIntegrationTest.kt
index ad67ae78981..9ce058b9c3f 100644
--- a/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/DistributedTracingIntegrationTest.kt
+++ b/sentry-spring-boot/src/integrationTestOtelAgent/kotlin/io/sentry/spring/boot/otelagentintegrationtest/DistributedTracingIntegrationTest.kt
@@ -47,8 +47,8 @@ class DistributedTracingIntegrationTest : IntegrationTest() {
)
assertThat(status).isEqualTo(500)
- // Longer than the 5 seconds the app's spans can wait for their batch to be exported.
- server.assertNoEnvelope(millis = 6_000) { isForRequest(it, id) }
+ // Ten times the app's batch delay, so the request's spans would have been exported.
+ server.assertNoEnvelope(millis = 1_000) { isForRequest(it, id) }
}
@Test
diff --git a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java
index 71c07604ef8..ec54ec292b0 100644
--- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java
+++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryAutoConfiguration.java
@@ -24,9 +24,11 @@
import io.sentry.spring.SpringSecuritySentryUserProvider;
import io.sentry.spring.boot.graphql.SentryGraphqlAutoConfiguration;
import io.sentry.spring.cache.SentryCacheBeanPostProcessor;
+import io.sentry.spring.checkin.SentryCheckInAdvice;
import io.sentry.spring.checkin.SentryCheckInAdviceConfiguration;
import io.sentry.spring.checkin.SentryCheckInPointcutConfiguration;
import io.sentry.spring.checkin.SentryQuartzConfiguration;
+import io.sentry.spring.checkin.SentryScheduledCheckInPointcut;
import io.sentry.spring.exception.SentryCaptureExceptionParameterPointcutConfiguration;
import io.sentry.spring.exception.SentryExceptionParameterAdviceConfiguration;
import io.sentry.spring.kafka.SentryKafkaConsumerBeanPostProcessor;
@@ -46,11 +48,16 @@
import java.util.List;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
+import org.aopalliance.aop.Advice;
import org.aspectj.lang.ProceedingJoinPoint;
import org.jetbrains.annotations.NotNull;
import org.quartz.core.QuartzScheduler;
import org.slf4j.MDC;
+import org.springframework.aop.Advisor;
+import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.condition.AnyNestedCondition;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
@@ -69,6 +76,7 @@
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.Role;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.Environment;
@@ -291,6 +299,27 @@ static class SentryCheckInAspectsConfiguration {
@Import(SentryCheckInPointcutConfiguration.class)
@Open
static class SentryCheckInPointcutAutoConfiguration {}
+
+ @Configuration(proxyBeanMethods = false)
+ @ConditionalOnProperty(name = "sentry.monitor-scheduled-tasks", havingValue = "true")
+ @Open
+ static class SentryScheduledCheckInConfiguration {
+ @Bean
+ @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
+ public @NotNull Advice sentryScheduledCheckInAdvice() {
+ return new SentryCheckInAdvice(ScopesAdapter.getInstance(), true);
+ }
+
+ @Bean
+ @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
+ public @NotNull Advisor sentryScheduledCheckInAdvisor(
+ final @NotNull @Qualifier("sentryScheduledCheckInAdvice") Advice advice,
+ final @NotNull SentryProperties sentryProperties) {
+ return new DefaultPointcutAdvisor(
+ new SentryScheduledCheckInPointcut(sentryProperties.getExcludeScheduledTasks()),
+ advice);
+ }
+ }
}
/** Registers beans specific to Spring MVC. */
diff --git a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java
index 7bc634d126c..d3b270e59e2 100644
--- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java
+++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/SentryProperties.java
@@ -40,6 +40,15 @@ public class SentryProperties extends SentryOptions {
/** Clients integration properties. */
private @NotNull Clients clients = new Clients();
+ /** Monitor every {@code @Scheduled} method with Sentry Crons. */
+ private boolean monitorScheduledTasks = false;
+
+ /**
+ * {@code @Scheduled} methods not to monitor when {@code monitorScheduledTasks} is on, as {@code
+ * ClassName.methodName} or a regex matching it.
+ */
+ private @NotNull List excludeScheduledTasks = new ArrayList<>();
+
public boolean isUseGitCommitIdAsRelease() {
return useGitCommitIdAsRelease;
}
@@ -109,6 +118,22 @@ public void setClients(@NotNull Clients clients) {
this.clients = clients;
}
+ public boolean isMonitorScheduledTasks() {
+ return monitorScheduledTasks;
+ }
+
+ public void setMonitorScheduledTasks(boolean monitorScheduledTasks) {
+ this.monitorScheduledTasks = monitorScheduledTasks;
+ }
+
+ public @NotNull List getExcludeScheduledTasks() {
+ return excludeScheduledTasks;
+ }
+
+ public void setExcludeScheduledTasks(@NotNull List excludeScheduledTasks) {
+ this.excludeScheduledTasks = excludeScheduledTasks;
+ }
+
@Open
public static class Clients {
/** Enable automatic instrumentation of Spring-managed OkHttp clients. Disabled by default. */
@@ -199,6 +224,9 @@ public static class Graphql {
/** List of error types the Sentry Graphql integration should ignore. */
private @NotNull List ignoredErrorTypes = new ArrayList<>();
+ /** Whether ignored error types should be matched against classification specifications. */
+ private boolean enableSpecificationErrorTypeFiltering = false;
+
@NotNull
public List getIgnoredErrorTypes() {
return ignoredErrorTypes;
@@ -207,5 +235,14 @@ public List getIgnoredErrorTypes() {
public void setIgnoredErrorTypes(final @NotNull List ignoredErrorTypes) {
this.ignoredErrorTypes = ignoredErrorTypes;
}
+
+ public boolean isEnableSpecificationErrorTypeFiltering() {
+ return enableSpecificationErrorTypeFiltering;
+ }
+
+ public void setEnableSpecificationErrorTypeFiltering(
+ final boolean enableSpecificationErrorTypeFiltering) {
+ this.enableSpecificationErrorTypeFiltering = enableSpecificationErrorTypeFiltering;
+ }
}
}
diff --git a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/graphql/SentryGraphqlAutoConfiguration.java b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/graphql/SentryGraphqlAutoConfiguration.java
index ebebe1f1499..40f6fcb2eb7 100644
--- a/sentry-spring-boot/src/main/java/io/sentry/spring/boot/graphql/SentryGraphqlAutoConfiguration.java
+++ b/sentry-spring-boot/src/main/java/io/sentry/spring/boot/graphql/SentryGraphqlAutoConfiguration.java
@@ -56,7 +56,8 @@ private SentryInstrumentation createInstrumentation(
beforeSpanCallback.getIfAvailable(),
new SentrySpringSubscriptionHandler(),
captureRequestBody,
- sentryProperties.getGraphql().getIgnoredErrorTypes());
+ sentryProperties.getGraphql().getIgnoredErrorTypes(),
+ sentryProperties.getGraphql().isEnableSpecificationErrorTypeFiltering());
}
@Bean
diff --git a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt
index 9b9501246e5..28b190de232 100644
--- a/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt
+++ b/sentry-spring-boot/src/test/kotlin/io/sentry/spring/boot/SentryAutoConfigurationTest.kt
@@ -18,6 +18,7 @@ import io.sentry.NoOpTransportFactory
import io.sentry.ProfileLifecycle
import io.sentry.SamplingContext
import io.sentry.Sentry
+import io.sentry.SentryEnvelope
import io.sentry.SentryEvent
import io.sentry.SentryIntegrationPackageStorage
import io.sentry.SentryLevel
@@ -47,6 +48,7 @@ import io.sentry.transport.ITransport
import io.sentry.transport.ITransportGate
import java.io.File
import java.lang.RuntimeException
+import java.util.concurrent.TimeUnit
import javax.servlet.Filter
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -55,9 +57,12 @@ import kotlin.test.assertTrue
import org.aspectj.lang.ProceedingJoinPoint
import org.assertj.core.api.Assertions.assertThat
import org.mockito.internal.util.MockUtil.isMock
+import org.mockito.kotlin.after
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
+import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.mock
+import org.mockito.kotlin.timeout
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.quartz.JobExecutionContext
@@ -68,6 +73,7 @@ import org.quartz.core.QuartzScheduler
import org.slf4j.MDC
import org.springframework.aop.support.NameMatchMethodPointcut
import org.springframework.boot.autoconfigure.AutoConfigurations
+import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration
import org.springframework.boot.autoconfigure.quartz.QuartzAutoConfiguration
import org.springframework.boot.autoconfigure.quartz.SchedulerFactoryBeanCustomizer
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
@@ -82,6 +88,8 @@ import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
+import org.springframework.scheduling.annotation.EnableScheduling
+import org.springframework.scheduling.annotation.Scheduled
import org.springframework.scheduling.quartz.SchedulerFactoryBean
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.client.RestTemplate
@@ -193,6 +201,33 @@ class SentryAutoConfigurationTest {
}
}
+ @Test
+ fun `GraphQL specification error type filtering is disabled by default`() {
+ dsnEnabledRunner.run {
+ assertThat(
+ it.getBean(SentryProperties::class.java).graphql.isEnableSpecificationErrorTypeFiltering
+ )
+ .isFalse()
+ }
+ }
+
+ @Test
+ fun `GraphQL specification error type filtering property is applied`() {
+ listOf(false, true).forEach { enabled ->
+ dsnEnabledRunner
+ .withPropertyValues("sentry.graphql.enable-specification-error-type-filtering=$enabled")
+ .run {
+ assertThat(
+ it
+ .getBean(SentryProperties::class.java)
+ .graphql
+ .isEnableSpecificationErrorTypeFiltering
+ )
+ .isEqualTo(enabled)
+ }
+ }
+ }
+
@Test
fun `properties are applied to SentryOptions`() {
contextRunner
@@ -1089,6 +1124,44 @@ class SentryAutoConfigurationTest {
.run { assertThat(it).doesNotHaveBean(SchedulerFactoryBeanCustomizer::class.java) }
}
+ @Test
+ fun `does not monitor @Scheduled methods by default`() {
+ contextRunner.withPropertyValues("sentry.dsn=http://key@localhost/proj").run {
+ assertThat(it).doesNotHaveBean("sentryScheduledCheckInAdvisor")
+ }
+ }
+
+ @Test
+ fun `monitors @Scheduled methods when monitor-scheduled-tasks is enabled`() {
+ baseContextRunner
+ .withPropertyValues(
+ "sentry.dsn=http://key@localhost/proj",
+ "sentry.monitor-scheduled-tasks=true",
+ "sentry.exclude-scheduled-tasks[0]=SentryAutoConfigurationTest.ScheduledJobs.excluded",
+ )
+ .withConfiguration(AutoConfigurations.of(AopAutoConfiguration::class.java))
+ .withUserConfiguration(
+ MockTransportConfiguration::class.java,
+ ScheduledJobsConfiguration::class.java,
+ )
+ .run {
+ assertThat(it).hasBean("sentryScheduledCheckInAdvisor")
+ val transport = it.getBean(ITransport::class.java)
+ val captor = argumentCaptor()
+ verify(transport, timeout(5000).times(2)).send(any(), anyOrNull())
+ verify(transport, after(500).times(2)).send(captor.capture(), anyOrNull())
+ val payloads =
+ captor.allValues.map { envelope -> String(envelope.items.first().data, Charsets.UTF_8) }
+ assertThat(payloads).allMatch { payload ->
+ payload.contains(
+ "\"monitor_slug\":\"sentry-auto-configuration-test-scheduled-jobs-sync\""
+ )
+ }
+ assertThat(payloads[0]).contains("\"value\":1,\"unit\":\"hour\"")
+ assertThat(payloads[1]).contains("\"status\":\"ok\"")
+ }
+ }
+
@Test
fun `Sentry quartz job listener is added`() {
contextRunner
@@ -1204,6 +1277,18 @@ class SentryAutoConfigurationTest {
@Bean open fun sentryTransport() = transport
}
+ @Configuration(proxyBeanMethods = false)
+ @EnableScheduling
+ open class ScheduledJobsConfiguration {
+ @Bean open fun scheduledJobs() = ScheduledJobs()
+ }
+
+ open class ScheduledJobs {
+ @Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS) open fun sync() {}
+
+ @Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS) open fun excluded() {}
+ }
+
@Configuration(proxyBeanMethods = false)
open class NoOpTransportConfiguration {
diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api
index 3dccdccfe2c..fc3064789ef 100644
--- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api
+++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api
@@ -141,12 +141,14 @@ public final class io/sentry/spring/jakarta/cache/SentryCacheWrapper : org/sprin
public abstract interface annotation class io/sentry/spring/jakarta/checkin/SentryCheckIn : java/lang/annotation/Annotation {
public abstract fun heartbeat ()Z
public abstract fun monitorSlug ()Ljava/lang/String;
+ public abstract fun upsertMonitorConfig ()Z
public abstract fun value ()Ljava/lang/String;
}
public class io/sentry/spring/jakarta/checkin/SentryCheckInAdvice : org/aopalliance/intercept/MethodInterceptor, org/springframework/context/EmbeddedValueResolverAware {
public fun ()V
public fun (Lio/sentry/IScopes;)V
+ public fun (Lio/sentry/IScopes;Z)V
public fun invoke (Lorg/aopalliance/intercept/MethodInvocation;)Ljava/lang/Object;
public fun setEmbeddedValueResolver (Lorg/springframework/util/StringValueResolver;)V
}
@@ -167,6 +169,11 @@ public class io/sentry/spring/jakarta/checkin/SentryQuartzConfiguration {
public fun schedulerFactoryBeanCustomizer ()Lorg/springframework/boot/autoconfigure/quartz/SchedulerFactoryBeanCustomizer;
}
+public final class io/sentry/spring/jakarta/checkin/SentryScheduledCheckInPointcut : org/springframework/aop/support/StaticMethodMatcherPointcut {
+ public fun (Ljava/util/List;)V
+ public fun matches (Ljava/lang/reflect/Method;Ljava/lang/Class;)Z
+}
+
public final class io/sentry/spring/jakarta/checkin/SentrySchedulerFactoryBeanCustomizer : org/springframework/boot/autoconfigure/quartz/SchedulerFactoryBeanCustomizer {
public fun ()V
public fun customize (Lorg/springframework/scheduling/quartz/SchedulerFactoryBean;)V
diff --git a/sentry-spring-jakarta/build.gradle.kts b/sentry-spring-jakarta/build.gradle.kts
index ee9b057ec6e..444d219f244 100644
--- a/sentry-spring-jakarta/build.gradle.kts
+++ b/sentry-spring-jakarta/build.gradle.kts
@@ -11,6 +11,9 @@ plugins {
alias(libs.plugins.gradle.versions)
alias(libs.plugins.buildconfig)
alias(libs.plugins.springboot3) apply false
+ id("io.sentry.spotless")
+ id("io.sentry.jdk-target")
+ id("io.sentry.publish")
id("io.sentry.api-validation")
}
@@ -34,7 +37,7 @@ dependencies {
compileOnly(Config.Libs.springWeb)
compileOnly(Config.Libs.springAop)
compileOnly(Config.Libs.springSecurityWeb)
- compileOnly(Config.Libs.aspectj)
+ compileOnly(libs.aspectjweaver)
compileOnly(libs.context.propagation)
compileOnly(libs.jetbrains.annotations)
compileOnly(libs.nopen.annotations)
diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java
index bfb6b724b8f..89b04aa561a 100644
--- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java
+++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java
@@ -29,6 +29,16 @@
*/
boolean heartbeat() default false;
+ /**
+ * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so
+ * Sentry creates or updates the monitor.
+ *
+ * Heartbeat check-ins never send a monitor config.
+ *
+ * @return true to send a monitor config, false by default
+ */
+ boolean upsertMonitorConfig() default false;
+
/**
* Monitor slug. If not set, no check-in will be sent.
*
diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java
index 1e3697e781d..a544c401966 100644
--- a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java
+++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java
@@ -6,13 +6,25 @@
import io.sentry.DateUtils;
import io.sentry.IScopes;
import io.sentry.ISentryLifecycleToken;
+import io.sentry.MonitorConfig;
import io.sentry.ScopesAdapter;
import io.sentry.SentryLevel;
import io.sentry.protocol.SentryId;
import io.sentry.time.Stopwatch;
+import io.sentry.util.MonitorConfigUtils;
import io.sentry.util.Objects;
import io.sentry.util.TracingUtils;
import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.format.DateTimeParseException;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+import java.util.zip.CRC32;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.jetbrains.annotations.ApiStatus;
@@ -20,7 +32,11 @@
import org.jetbrains.annotations.Nullable;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.EmbeddedValueResolverAware;
+import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.scheduling.annotation.Schedules;
+import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringValueResolver;
@@ -31,16 +47,44 @@
@ApiStatus.Internal
@Open
public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueResolverAware {
+ // Spring before 5.3 parses crons with CronSequenceGenerator
+ private static final boolean LEGACY_CRON_PARSER =
+ !ClassUtils.isPresent(
+ "org.springframework.scheduling.support.CronExpression",
+ SentryCheckInAdvice.class.getClassLoader());
+
+ private static final @NotNull Pattern WORD_BOUNDARY =
+ Pattern.compile("(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])");
+ private static final int MAX_SLUG_LENGTH = 50;
+
private final @NotNull IScopes scopes;
+ private final boolean monitorScheduledTasks;
+
private @Nullable StringValueResolver resolver;
+ private final @NotNull Map monitorConfigs =
+ new ConcurrentHashMap<>();
+
+ private final @NotNull Map derivedSlugOwners = new ConcurrentHashMap<>();
+
+ private final @NotNull Set collidingSlugs = ConcurrentHashMap.newKeySet();
+
public SentryCheckInAdvice() {
this(ScopesAdapter.getInstance());
}
public SentryCheckInAdvice(final @NotNull IScopes scopes) {
+ this(scopes, false);
+ }
+
+ /**
+ * @param monitorScheduledTasks whether to also monitor {@code @Scheduled} methods without {@link
+ * SentryCheckIn}, using a slug derived from the class and method name
+ */
+ public SentryCheckInAdvice(final @NotNull IScopes scopes, final boolean monitorScheduledTasks) {
this.scopes = Objects.requireNonNull(scopes, "scopes are required");
+ this.monitorScheduledTasks = monitorScheduledTasks;
}
@Override
@@ -52,6 +96,24 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl
SentryCheckIn checkInAnnotation =
AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class);
if (checkInAnnotation == null) {
+ if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) {
+ final @NotNull Class> targetClass = invocation.getThis().getClass();
+ final @NotNull String taskName = scheduledTaskName(targetClass, mostSpecificMethod);
+ final @Nullable MonitorConfig monitorConfig = monitorConfig(mostSpecificMethod);
+ if (monitorConfig == null) {
+ scopes
+ .getOptions()
+ .getLogger()
+ .log(
+ SentryLevel.DEBUG,
+ "Not monitoring %s because Sentry Crons can't express its @Scheduled schedule.",
+ taskName);
+ return invocation.proceed();
+ }
+ final @NotNull String monitorSlug = scheduledTaskSlug(taskName);
+ warnOnSlugCollision(monitorSlug, targetClass, mostSpecificMethod);
+ return captureCheckIns(invocation, monitorSlug, false, monitorConfig);
+ }
return invocation.proceed();
}
@@ -87,6 +149,19 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl
return invocation.proceed();
}
+ final @Nullable MonitorConfig monitorConfig =
+ !isHeartbeatOnly && checkInAnnotation.upsertMonitorConfig()
+ ? monitorConfig(mostSpecificMethod)
+ : null;
+ return captureCheckIns(invocation, monitorSlug, isHeartbeatOnly, monitorConfig);
+ }
+
+ private Object captureCheckIns(
+ final @NotNull MethodInvocation invocation,
+ final @NotNull String monitorSlug,
+ final boolean isHeartbeatOnly,
+ final @Nullable MonitorConfig monitorConfig)
+ throws Throwable {
try (final @NotNull ISentryLifecycleToken ignored =
scopes.forkedScopes("SentryCheckInAdvice").makeCurrent()) {
TracingUtils.startNewTrace(scopes);
@@ -98,7 +173,9 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl
try {
if (!isHeartbeatOnly) {
- checkInId = scopes.captureCheckIn(new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS));
+ final @NotNull CheckIn inProgress = new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS);
+ inProgress.setMonitorConfig(monitorConfig);
+ checkInId = scopes.captureCheckIn(inProgress);
}
return invocation.proceed();
} catch (Throwable e) {
@@ -113,6 +190,144 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl
}
}
+ private void warnOnSlugCollision(
+ final @NotNull String monitorSlug,
+ final @NotNull Class> targetClass,
+ final @NotNull Method method) {
+ final @NotNull String identity =
+ ClassUtils.getUserClass(targetClass).getName() + "." + method.getName();
+ final @Nullable String owner = derivedSlugOwners.putIfAbsent(monitorSlug, identity);
+ if (owner != null && !owner.equals(identity) && collidingSlugs.add(monitorSlug)) {
+ scopes
+ .getOptions()
+ .getLogger()
+ .log(
+ SentryLevel.WARNING,
+ "Monitor slug \"%s\" is used by both %s and %s, so their check-ins go to one monitor. Add @SentryCheckIn(\"your-slug\") to one of them.",
+ monitorSlug,
+ owner,
+ identity);
+ }
+ }
+
+ static boolean isScheduled(final @NotNull Method method) {
+ return !AnnotatedElementUtils.getMergedRepeatableAnnotations(
+ method, Scheduled.class, Schedules.class)
+ .isEmpty();
+ }
+
+ /** {@code ClassName.methodName}, with nested classes as {@code Outer.Inner}. */
+ static @NotNull String scheduledTaskName(
+ final @NotNull Class> targetClass, final @NotNull Method method) {
+ return ClassUtils.getShortName(ClassUtils.getUserClass(targetClass)) + "." + method.getName();
+ }
+
+ static @NotNull String scheduledTaskSlug(final @NotNull String taskName) {
+ final @NotNull String slug =
+ WORD_BOUNDARY
+ .matcher(taskName)
+ .replaceAll("-")
+ .toLowerCase(Locale.ROOT)
+ .replaceAll("[^a-z0-9]+", "-")
+ .replaceAll("^-+|-+$", "");
+ if (slug.length() <= MAX_SLUG_LENGTH) {
+ return slug;
+ }
+ // keep truncated slugs unique
+ final @NotNull CRC32 crc = new CRC32();
+ crc.update(slug.getBytes(StandardCharsets.UTF_8));
+ final @NotNull String hash = String.format(Locale.ROOT, "%08x", crc.getValue());
+ return slug.substring(0, MAX_SLUG_LENGTH - hash.length() - 1).replaceAll("-+$", "")
+ + "-"
+ + hash;
+ }
+
+ private @Nullable MonitorConfig monitorConfig(final @NotNull Method method) {
+ return monitorConfigs.computeIfAbsent(
+ method, key -> new CachedMonitorConfig(monitorConfigFromScheduled(key)))
+ .config;
+ }
+
+ private @Nullable MonitorConfig monitorConfigFromScheduled(final @NotNull Method method) {
+ try {
+ final @NotNull Set schedules =
+ AnnotatedElementUtils.getMergedRepeatableAnnotations(
+ method, Scheduled.class, Schedules.class);
+ if (schedules.size() != 1) {
+ return null;
+ }
+ final @NotNull Scheduled scheduled = schedules.iterator().next();
+ // timeUnit only exists from Spring 5.3.10, so read it as an attribute
+ final @Nullable Object timeUnitAttribute =
+ AnnotationUtils.getAnnotationAttributes(scheduled).get("timeUnit");
+ final @NotNull TimeUnit timeUnit =
+ timeUnitAttribute instanceof TimeUnit unit ? unit : TimeUnit.MILLISECONDS;
+ final @Nullable String cron = resolve(scheduled.cron());
+ // Spring only reads the zone for a cron
+ final @Nullable String zone =
+ cron == null || cron.isEmpty() ? null : resolve(scheduled.zone());
+ return MonitorConfigUtils.fromSpringScheduled(
+ cron,
+ zone,
+ periodMillis(scheduled.fixedRate(), scheduled.fixedRateString(), timeUnit),
+ periodMillis(scheduled.fixedDelay(), scheduled.fixedDelayString(), timeUnit),
+ LEGACY_CRON_PARSER);
+ } catch (RuntimeException e) {
+ scopes
+ .getOptions()
+ .getLogger()
+ .log(SentryLevel.WARNING, "Could not derive a monitor config from @Scheduled.", e);
+ return null;
+ }
+ }
+
+ private @Nullable Long periodMillis(
+ final long value, final @NotNull String valueString, final @NotNull TimeUnit timeUnit) {
+ if (value >= 0) {
+ return timeUnit.toMillis(value);
+ }
+ final @Nullable String resolved = resolve(valueString);
+ if (resolved == null || resolved.isEmpty()) {
+ return null;
+ }
+ final @NotNull String trimmed = resolved.trim();
+ @Nullable Long millis = null;
+ if (trimmed.startsWith("P") || trimmed.startsWith("p")) {
+ try {
+ millis = Duration.parse(trimmed).toMillis();
+ } catch (DateTimeParseException | ArithmeticException e) {
+ // logged below
+ }
+ } else {
+ millis = MonitorConfigUtils.parsePeriodMillis(trimmed, timeUnit);
+ }
+ if (millis == null) {
+ scopes
+ .getOptions()
+ .getLogger()
+ .log(
+ SentryLevel.DEBUG,
+ "Not sending a monitor config because the @Scheduled period '%s' could not be parsed.",
+ trimmed);
+ }
+ return millis;
+ }
+
+ private @Nullable String resolve(final @NotNull String value) {
+ if (resolver == null || value.isEmpty()) {
+ return value;
+ }
+ return resolver.resolveStringValue(value);
+ }
+
+ private static final class CachedMonitorConfig {
+ private final @Nullable MonitorConfig config;
+
+ private CachedMonitorConfig(final @Nullable MonitorConfig config) {
+ this.config = config;
+ }
+ }
+
@Override
public void setEmbeddedValueResolver(StringValueResolver resolver) {
this.resolver = resolver;
diff --git a/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInPointcut.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInPointcut.java
new file mode 100644
index 00000000000..96b4098c2aa
--- /dev/null
+++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInPointcut.java
@@ -0,0 +1,66 @@
+package io.sentry.spring.jakarta.checkin;
+
+import io.sentry.FilterString;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.List;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.springframework.aop.support.AopUtils;
+import org.springframework.aop.support.StaticMethodMatcherPointcut;
+import org.springframework.core.ReactiveAdapterRegistry;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Matches {@code @Scheduled} methods without {@link SentryCheckIn}, except excluded ones. Excludes
+ * match the task name ({@code ClassName.methodName}) exactly or as a regex.
+ */
+@ApiStatus.Internal
+public final class SentryScheduledCheckInPointcut extends StaticMethodMatcherPointcut {
+ private final @NotNull List excludedScheduledTasks = new ArrayList<>();
+
+ public SentryScheduledCheckInPointcut(final @Nullable List excludedScheduledTasks) {
+ if (excludedScheduledTasks != null) {
+ for (final @NotNull String task : excludedScheduledTasks) {
+ if (!task.trim().isEmpty()) {
+ this.excludedScheduledTasks.add(new FilterString(task.trim()));
+ }
+ }
+ }
+ }
+
+ @Override
+ public boolean matches(final @NotNull Method method, final @NotNull Class> targetClass) {
+ final @NotNull Class> userClass = ClassUtils.getUserClass(targetClass);
+ // CGLIB can't intercept these, and Spring calls private and static methods on the target
+ final int modifiers = method.getModifiers();
+ if (Modifier.isFinal(userClass.getModifiers())
+ || Modifier.isFinal(modifiers)
+ || Modifier.isPrivate(modifiers)
+ || Modifier.isStatic(modifiers)) {
+ return false;
+ }
+ final @NotNull Method specificMethod = AopUtils.getMostSpecificMethod(method, userClass);
+ if (!SentryCheckInAdvice.isScheduled(specificMethod)
+ || AnnotationUtils.findAnnotation(specificMethod, SentryCheckIn.class) != null) {
+ return false;
+ }
+ // Spring calls reactive and suspend methods once and subscribes to the result on each run
+ if (specificMethod.getParameterCount() > 0
+ || ReactiveAdapterRegistry.getSharedInstance().getAdapter(specificMethod.getReturnType())
+ != null) {
+ return false;
+ }
+ final @NotNull String taskName =
+ SentryCheckInAdvice.scheduledTaskName(userClass, specificMethod);
+ for (final @NotNull FilterString excluded : excludedScheduledTasks) {
+ if (excluded.getFilterString().equals(taskName) || excluded.matches(taskName)) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt
index c6edd834530..84f6e2a8118 100644
--- a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt
+++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt
@@ -4,16 +4,21 @@ import io.sentry.CheckIn
import io.sentry.CheckInStatus
import io.sentry.IScopes
import io.sentry.ISentryLifecycleToken
+import io.sentry.MonitorConfig
import io.sentry.Sentry
import io.sentry.SentryOptions
import io.sentry.protocol.SentryId
import io.sentry.spring.jakarta.checkin.SentryCheckIn
import io.sentry.spring.jakarta.checkin.SentryCheckInAdviceConfiguration
import io.sentry.spring.jakarta.checkin.SentryCheckInPointcutConfiguration
+import java.util.TimeZone
+import java.util.concurrent.TimeUnit
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertSame
import org.junit.jupiter.api.assertThrows
import org.junit.runner.RunWith
import org.mockito.kotlin.any
@@ -32,6 +37,8 @@ import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.EnableAspectJAutoProxy
import org.springframework.context.annotation.Import
import org.springframework.context.support.PropertySourcesPlaceholderConfigurer
+import org.springframework.scheduling.annotation.Scheduled
+import org.springframework.scheduling.annotation.Schedules
import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig
import org.springframework.test.context.junit4.SpringRunner
@@ -39,7 +46,15 @@ import org.springframework.util.StringValueResolver
@RunWith(SpringRunner::class)
@SpringJUnitConfig(SentryCheckInAdviceTest.Config::class)
-@TestPropertySource(properties = ["my.cron.slug = mypropertycronslug"])
+@TestPropertySource(
+ properties =
+ [
+ "my.cron.slug = mypropertycronslug",
+ "my.cron.schedule = 0 30 2 * * *",
+ "my.cron.zone = America/New_York",
+ "my.cron.empty.zone = ",
+ ]
+)
class SentryCheckInAdviceTest {
@Autowired lateinit var sampleService: SampleService
@@ -50,6 +65,10 @@ class SentryCheckInAdviceTest {
@Autowired lateinit var sampleServiceSpringProperties: SampleServiceSpringProperties
+ @Autowired lateinit var sampleServiceScheduled: SampleServiceScheduled
+
+ @Autowired lateinit var sampleServiceInheritedSchedule: SampleServiceInheritedSchedule
+
@Autowired lateinit var scopes: IScopes
val lifecycleToken = mock()
@@ -74,6 +93,7 @@ class SentryCheckInAdviceTest {
val inProgressCheckIn = checkInCaptor.firstValue
assertEquals("monitor_slug_1", inProgressCheckIn.monitorSlug)
assertEquals(CheckInStatus.IN_PROGRESS.apiName(), inProgressCheckIn.status)
+ assertNull(inProgressCheckIn.monitorConfig)
val doneCheckIn = checkInCaptor.lastValue
assertEquals("monitor_slug_1", doneCheckIn.monitorSlug)
@@ -228,6 +248,160 @@ class SentryCheckInAdviceTest {
order.verify(lifecycleToken).close()
}
+ @Test
+ fun `cron with zone is sent as crontab monitor config`() {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZone() }
+ assertNotNull(config)
+ assertEquals("crontab", config.schedule.type)
+ assertEquals("15 10 * * 1-5", config.schedule.value)
+ assertNull(config.schedule.unit)
+ assertEquals("Europe/Vienna", config.timezone)
+ }
+
+ @Test
+ fun `cron without zone is sent in the JVM default zone`() {
+ val defaultTimeZone = TimeZone.getDefault()
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo"))
+ try {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.cron() }
+ assertEquals("0 2 * * *", config?.schedule?.value)
+ assertEquals("Asia/Tokyo", config?.timezone)
+ } finally {
+ TimeZone.setDefault(defaultTimeZone)
+ }
+ }
+
+ @Test
+ fun `cron and zone placeholders are resolved`() {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.cronFromProperties() }
+ assertEquals("30 2 * * *", config?.schedule?.value)
+ assertEquals("America/New_York", config?.timezone)
+ }
+
+ @Test
+ fun `fixed rate with time unit is sent as interval monitor config`() {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() }
+ assertNotNull(config)
+ assertEquals("interval", config.schedule.type)
+ assertEquals("2", config.schedule.value)
+ assertEquals("hour", config.schedule.unit)
+ }
+
+ @Test
+ fun `ISO-8601 fixed rate string is sent as interval monitor config`() {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateIso() }
+ assertEquals("10", config?.schedule?.value)
+ assertEquals("minute", config?.schedule?.unit)
+ }
+
+ @Test
+ fun `simple duration fixed rate string is sent as interval monitor config`() {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateSimpleDuration() }
+ assertEquals("interval", config?.schedule?.type)
+ assertEquals("5", config?.schedule?.value)
+ assertEquals("minute", config?.schedule?.unit)
+ }
+
+ @Test
+ fun `fixed delay sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelay() })
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelayIso() })
+ }
+
+ @Test
+ fun `disabled cron sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.disabledCron() })
+ }
+
+ @Test
+ fun `zone placeholder resolving to empty uses the JVM default zone`() {
+ val defaultTimeZone = TimeZone.getDefault()
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo"))
+ try {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithEmptyZone() }
+ assertEquals("0 3 * * *", config?.schedule?.value)
+ assertEquals("Asia/Tokyo", config?.timezone)
+ } finally {
+ TimeZone.setDefault(defaultTimeZone)
+ }
+ }
+
+ @Test
+ fun `cron zone overrides the default timezone from options`() {
+ val options =
+ SentryOptions().apply {
+ cron = SentryOptions.Cron().apply { defaultTimezone = "Europe/Berlin" }
+ }
+ whenever(scopes.options).thenReturn(options)
+ val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZoneAndDefaults() }
+ assertEquals("Europe/Vienna", config?.timezone)
+ }
+
+ @Test
+ fun `unresolvable cron placeholder sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.unresolvableCron() })
+ }
+
+ @Test
+ fun `exception while deriving monitor config sends no monitor config and runs the job`() {
+ var result = 0
+ assertNull(inProgressMonitorConfig { result = sampleServiceScheduled.derivationThrows() })
+ assertEquals(1, result)
+ }
+
+ @Test
+ fun `monitor config is derived once per method`() {
+ val first = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() }
+ val second = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() }
+ assertNotNull(first)
+ assertSame(first, second)
+ }
+
+ @Test
+ fun `multiple schedules send no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.multipleSchedules() })
+ }
+
+ @Test
+ fun `schedules container sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.schedulesContainer() })
+ }
+
+ @Test
+ fun `upsertMonitorConfig false sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDisabled() })
+ }
+
+ @Test
+ fun `upsertMonitorConfig defaults to false`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDefault() })
+ }
+
+ @Test
+ fun `@Scheduled on an overridden parent method sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceInheritedSchedule.job() })
+ }
+
+ @Test
+ fun `heartbeat with @Scheduled sends a single check-in without monitor config`() {
+ val checkInCaptor = argumentCaptor()
+ whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId())
+ sampleServiceScheduled.heartbeat()
+ assertEquals(1, checkInCaptor.allValues.size)
+ assertEquals(CheckInStatus.OK.apiName(), checkInCaptor.firstValue.status)
+ assertNull(checkInCaptor.firstValue.monitorConfig)
+ }
+
+ private fun inProgressMonitorConfig(block: () -> Unit): MonitorConfig? {
+ val checkInCaptor = argumentCaptor()
+ whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId())
+ block()
+ assertEquals(2, checkInCaptor.allValues.size)
+ assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkInCaptor.firstValue.status)
+ assertNull(checkInCaptor.lastValue.monitorConfig)
+ return checkInCaptor.firstValue.monitorConfig
+ }
+
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true)
@Import(SentryCheckInAdviceConfiguration::class, SentryCheckInPointcutConfiguration::class)
@@ -241,6 +415,10 @@ class SentryCheckInAdviceTest {
@Bean open fun sampleServiceSpringProperties() = SampleServiceSpringProperties()
+ @Bean open fun sampleServiceScheduled() = SampleServiceScheduled()
+
+ @Bean open fun sampleServiceInheritedSchedule() = SampleServiceInheritedSchedule()
+
@Bean
open fun scopes(): IScopes {
val scopes = mock()
@@ -291,6 +469,88 @@ class SentryCheckInAdviceTest {
open fun helloExceptionProperty() = 1
}
+ open class SampleServiceScheduled {
+
+ @SentryCheckIn("cron_zone", upsertMonitorConfig = true)
+ @Scheduled(cron = "0 15 10 * * MON-FRI", zone = "Europe/Vienna")
+ open fun cronWithZone() {}
+
+ @SentryCheckIn("cron", upsertMonitorConfig = true)
+ @Scheduled(cron = "0 0 2 * * *")
+ open fun cron() {}
+
+ @SentryCheckIn("cron_properties", upsertMonitorConfig = true)
+ @Scheduled(cron = "\${my.cron.schedule}", zone = "\${my.cron.zone}")
+ open fun cronFromProperties() {}
+
+ @SentryCheckIn("fixed_rate_hours", upsertMonitorConfig = true)
+ @Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS)
+ open fun fixedRateHours() {}
+
+ @SentryCheckIn("fixed_rate_iso", upsertMonitorConfig = true)
+ @Scheduled(fixedRateString = "PT10M")
+ open fun fixedRateIso() {}
+
+ @SentryCheckIn("fixed_rate_simple", upsertMonitorConfig = true)
+ @Scheduled(fixedRateString = "5m")
+ open fun fixedRateSimpleDuration() {}
+
+ @SentryCheckIn("fixed_delay", upsertMonitorConfig = true)
+ @Scheduled(fixedDelay = 300_000)
+ open fun fixedDelay() {}
+
+ @SentryCheckIn("fixed_delay_iso", upsertMonitorConfig = true)
+ @Scheduled(fixedDelayString = "PT10M")
+ open fun fixedDelayIso() {}
+
+ @SentryCheckIn("disabled_cron", upsertMonitorConfig = true)
+ @Scheduled(cron = "-")
+ open fun disabledCron() {}
+
+ @SentryCheckIn("cron_empty_zone", upsertMonitorConfig = true)
+ @Scheduled(cron = "0 0 3 * * *", zone = "\${my.cron.empty.zone}")
+ open fun cronWithEmptyZone() {}
+
+ @SentryCheckIn("cron_zone_defaults", upsertMonitorConfig = true)
+ @Scheduled(cron = "0 0 4 * * *", zone = "Europe/Vienna")
+ open fun cronWithZoneAndDefaults() {}
+
+ @SentryCheckIn("schedules_container", upsertMonitorConfig = true)
+ @Schedules(Scheduled(cron = "0 0 1 * * *"), Scheduled(cron = "0 0 13 * * *"))
+ open fun schedulesContainer() {}
+
+ @SentryCheckIn("unresolvable_cron", upsertMonitorConfig = true)
+ @Scheduled(cron = "\${my.cron.missing}")
+ open fun unresolvableCron() {}
+
+ @SentryCheckIn("derivation_throws", upsertMonitorConfig = true)
+ @Scheduled(cron = "\${my.cron.exception.property}")
+ open fun derivationThrows() = 1
+
+ @SentryCheckIn("multiple", upsertMonitorConfig = true)
+ @Scheduled(cron = "0 0 1 * * *")
+ @Scheduled(cron = "0 0 13 * * *")
+ open fun multipleSchedules() {}
+
+ @SentryCheckIn("upsert_disabled", upsertMonitorConfig = false)
+ @Scheduled(cron = "0 0 1 * * *")
+ open fun upsertDisabled() {}
+
+ @SentryCheckIn("upsert_default") @Scheduled(cron = "0 0 1 * * *") open fun upsertDefault() {}
+
+ @SentryCheckIn("heartbeat", heartbeat = true, upsertMonitorConfig = true)
+ @Scheduled(cron = "0 0 1 * * *")
+ open fun heartbeat() {}
+ }
+
+ open class ScheduledParent {
+ @Scheduled(cron = "0 0 1 * * *") open fun job() {}
+ }
+
+ open class SampleServiceInheritedSchedule : ScheduledParent() {
+ @SentryCheckIn("inherited", upsertMonitorConfig = true) override fun job() {}
+ }
+
class MyPropertyPlaceholderConfigurer : PropertySourcesPlaceholderConfigurer() {
override fun doProcessProperties(
diff --git a/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInTest.kt
new file mode 100644
index 00000000000..00a2864b3f3
--- /dev/null
+++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInTest.kt
@@ -0,0 +1,365 @@
+package io.sentry.spring.jakarta.checkin
+
+import io.sentry.CheckIn
+import io.sentry.CheckInStatus
+import io.sentry.ILogger
+import io.sentry.IScopes
+import io.sentry.ISentryLifecycleToken
+import io.sentry.SentryLevel
+import io.sentry.SentryOptions
+import io.sentry.protocol.SentryId
+import java.lang.reflect.Modifier
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import org.junit.jupiter.api.assertThrows
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.atLeast
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.reset
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.springframework.aop.Advisor
+import org.springframework.aop.Pointcut
+import org.springframework.aop.framework.ProxyFactory
+import org.springframework.aop.support.AopUtils
+import org.springframework.aop.support.DefaultPointcutAdvisor
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.beans.factory.annotation.Qualifier
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.context.annotation.EnableAspectJAutoProxy
+import org.springframework.context.annotation.Import
+import org.springframework.context.support.PropertySourcesPlaceholderConfigurer
+import org.springframework.scheduling.annotation.Scheduled
+import org.springframework.test.context.TestPropertySource
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig
+import org.springframework.test.context.junit4.SpringRunner
+import reactor.core.publisher.Mono
+
+@RunWith(SpringRunner::class)
+@SpringJUnitConfig(SentryScheduledCheckInTest.Config::class)
+@TestPropertySource(properties = ["my.cron.schedule = 0 30 2 * * *"])
+class SentryScheduledCheckInTest {
+ private val SLUG = "sentry-scheduled-check-in-test-jobs-sync-users"
+ private val COLLISION_WARNING =
+ "Monitor slug \"%s\" is used by both %s and %s, so their check-ins go to one monitor. Add @SentryCheckIn(\"your-slug\") to one of them."
+
+ @Autowired lateinit var jobs: Jobs
+
+ @Autowired lateinit var excludedJobs: ExcludedJobs
+
+ @Autowired lateinit var finalJobs: FinalJobs
+
+ @Autowired lateinit var overridingJobs: OverridingJobs
+
+ @Autowired lateinit var scopes: IScopes
+
+ @BeforeTest
+ fun setup() {
+ reset(scopes)
+ whenever(scopes.options).thenReturn(SentryOptions())
+ whenever(scopes.forkedScopes(any())).thenReturn(scopes)
+ whenever(scopes.makeCurrent()).thenReturn(mock())
+ whenever(scopes.captureCheckIn(any())).thenReturn(SentryId())
+ }
+
+ @Test
+ fun `scheduled method without @SentryCheckIn is monitored with a derived slug and config`() {
+ val checkIns = capture { jobs.syncUsers() }
+ assertEquals(2, checkIns.size)
+ assertEquals(SLUG, checkIns[0].monitorSlug)
+ assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkIns[0].status)
+ val config = assertNotNull(checkIns[0].monitorConfig)
+ assertEquals("0 2 * * *", config.schedule.value)
+ assertEquals("Europe/Vienna", config.timezone)
+ assertEquals(SLUG, checkIns[1].monitorSlug)
+ assertEquals(CheckInStatus.OK.apiName(), checkIns[1].status)
+ }
+
+ @Test
+ fun `cron placeholder is resolved`() {
+ val checkIns = capture { jobs.fromProperty() }
+ assertEquals("30 2 * * *", checkIns[0].monitorConfig!!.schedule.value)
+ }
+
+ @Test
+ fun `tasks with unsupported schedules are not monitored`() {
+ jobs.fixedDelay()
+ verify(scopes, never()).captureCheckIn(any())
+ }
+
+ @Test
+ fun `@SentryCheckIn with an unsupported schedule checks in without config`() {
+ val checkIns = capture { jobs.explicitFixedDelay() }
+ assertEquals(listOf("explicit-delay", "explicit-delay"), checkIns.map { it.monitorSlug })
+ assertNull(checkIns[0].monitorConfig)
+ }
+
+ @Test
+ fun `failing job sends an error check-in and rethrows`() {
+ val checkIns = capture { assertThrows { jobs.oops() } }
+ assertEquals(CheckInStatus.ERROR.apiName(), checkIns[1].status)
+ }
+
+ @Test
+ fun `@SentryCheckIn keeps its slug and is only checked in once`() {
+ val checkIns = capture { jobs.explicit() }
+ assertEquals(listOf("explicit", "explicit"), checkIns.map { it.monitorSlug })
+ assertNull(checkIns[0].monitorConfig)
+ }
+
+ @Test
+ fun `methods without @Scheduled are not monitored`() {
+ jobs.notScheduled()
+ verify(scopes, never()).captureCheckIn(any())
+ }
+
+ @Test
+ fun `overriding methods without @Scheduled are not monitored`() {
+ overridingJobs.run()
+ verify(scopes, never()).captureCheckIn(any())
+ }
+
+ @Test
+ fun `reactive methods are not monitored`() {
+ jobs.reactive().block()
+ verify(scopes, never()).captureCheckIn(any())
+ }
+
+ @Test
+ fun `excluded tasks are not monitored`() {
+ jobs.excluded()
+ excludedJobs.run()
+ verify(scopes, never()).captureCheckIn(any())
+ assertFalse(AopUtils.isAopProxy(excludedJobs))
+ }
+
+ @Test
+ fun `final classes are not proxied`() {
+ finalJobs.run()
+ verify(scopes, never()).captureCheckIn(any())
+ assertFalse(AopUtils.isAopProxy(finalJobs))
+ assertTrue(AopUtils.isAopProxy(jobs))
+ }
+
+ @Test
+ fun `private and static methods are not matched`() {
+ val pointcut = SentryScheduledCheckInPointcut(null)
+ val privateMethod = HiddenJobs::class.java.getDeclaredMethod("hidden")
+ val staticMethod = HiddenJobs::class.java.getDeclaredMethod("static")
+ assertTrue(Modifier.isPrivate(privateMethod.modifiers))
+ assertTrue(Modifier.isStatic(staticMethod.modifiers))
+ assertFalse(pointcut.matches(privateMethod, HiddenJobs::class.java))
+ assertFalse(pointcut.matches(staticMethod, HiddenJobs::class.java))
+ assertTrue(pointcut.matches(Jobs::class.java.getMethod("syncUsers"), Jobs::class.java))
+ }
+
+ @Test
+ fun `slugs are kebab case and at most 50 characters`() {
+ assertEquals(
+ "report-jobs-send-daily-report",
+ SentryCheckInAdvice.scheduledTaskSlug("ReportJobs.sendDailyReport"),
+ )
+ assertEquals(
+ "http-client-jobs-run",
+ SentryCheckInAdvice.scheduledTaskSlug("HTTPClientJobs.run"),
+ )
+ assertEquals("outer-inner-run2-fa", SentryCheckInAdvice.scheduledTaskSlug("Outer.Inner.run2FA"))
+ val first =
+ SentryCheckInAdvice.scheduledTaskSlug(
+ "AVeryLongScheduledJobClassNameForReporting.firstMethod"
+ )
+ val second =
+ SentryCheckInAdvice.scheduledTaskSlug(
+ "AVeryLongScheduledJobClassNameForReporting.secondMethod"
+ )
+ assertTrue(first.length <= 50)
+ assertTrue(first.startsWith("a-very-long-scheduled-job-class-name-for-"))
+ assertTrue(first != second)
+ assertEquals(
+ first,
+ SentryCheckInAdvice.scheduledTaskSlug(
+ "AVeryLongScheduledJobClassNameForReporting.firstMethod"
+ ),
+ )
+ }
+
+ @Test
+ fun `task name uses the user class`() {
+ assertEquals(
+ "SentryScheduledCheckInTest.Jobs.syncUsers",
+ SentryCheckInAdvice.scheduledTaskName(
+ jobs.javaClass,
+ Jobs::class.java.getMethod("syncUsers"),
+ ),
+ )
+ }
+
+ @Test
+ fun `warns once when two methods derive the same slug`() {
+ val logger = mockLogger()
+ val advice = SentryCheckInAdvice(scopes, true)
+ val cleanupJob = proxy(CleanupJob(), advice)
+ val cleanup = proxy(Cleanup(), advice)
+
+ cleanupJob.run()
+ cleanup.jobRun()
+ cleanup.jobRun()
+ cleanupJob.run()
+
+ verify(logger, times(1))
+ .log(
+ eq(SentryLevel.WARNING),
+ eq(COLLISION_WARNING),
+ eq("sentry-scheduled-check-in-test-cleanup-job-run"),
+ eq("${CleanupJob::class.java.name}.run"),
+ eq("${Cleanup::class.java.name}.jobRun"),
+ )
+ }
+
+ @Test
+ fun `does not warn when derived slugs differ or the same method runs again`() {
+ val logger = mockLogger()
+ val advice = SentryCheckInAdvice(scopes, true)
+ val cleanupJob = proxy(CleanupJob(), advice)
+ val otherJobs = proxy(Jobs(), advice)
+
+ cleanupJob.run()
+ cleanupJob.run()
+ otherJobs.syncUsers()
+
+ verify(logger, never()).log(eq(SentryLevel.WARNING), eq(COLLISION_WARNING), any(), any(), any())
+ }
+
+ private fun mockLogger(): ILogger {
+ val logger = mock()
+ val options =
+ SentryOptions().apply {
+ isDebug = true
+ setLogger(logger)
+ }
+ whenever(scopes.options).thenReturn(options)
+ return logger
+ }
+
+ private fun proxy(target: T, advice: SentryCheckInAdvice): T {
+ val factory = ProxyFactory(target)
+ factory.isProxyTargetClass = true
+ factory.addAdvice(advice)
+ @Suppress("UNCHECKED_CAST")
+ return factory.proxy as T
+ }
+
+ private fun capture(block: () -> Unit): List {
+ val captor = argumentCaptor()
+ block()
+ verify(scopes, atLeast(0)).captureCheckIn(captor.capture())
+ return captor.allValues
+ }
+
+ @Configuration
+ @EnableAspectJAutoProxy(proxyTargetClass = true)
+ @Import(SentryCheckInPointcutConfiguration::class)
+ open class Config {
+
+ @Bean open fun jobs() = Jobs()
+
+ @Bean open fun excludedJobs() = ExcludedJobs()
+
+ @Bean open fun finalJobs() = FinalJobs()
+
+ @Bean open fun overridingJobs() = OverridingJobs()
+
+ @Bean open fun scopes(): IScopes = mock()
+
+ @Bean
+ open fun sentryCheckInAdvisor(
+ @Qualifier("sentryCheckInPointcut") pointcut: Pointcut,
+ scopes: IScopes,
+ ): Advisor = DefaultPointcutAdvisor(pointcut, SentryCheckInAdvice(scopes))
+
+ @Bean open fun sentryScheduledCheckInAdvice(scopes: IScopes) = SentryCheckInAdvice(scopes, true)
+
+ @Bean
+ open fun sentryScheduledCheckInAdvisor(
+ @Qualifier("sentryScheduledCheckInAdvice") advice: SentryCheckInAdvice
+ ): Advisor =
+ DefaultPointcutAdvisor(
+ SentryScheduledCheckInPointcut(
+ listOf("SentryScheduledCheckInTest.Jobs.excluded", ".*\\.ExcludedJobs\\..*", " ")
+ ),
+ advice,
+ )
+
+ companion object {
+ @Bean
+ @JvmStatic
+ fun propertySourcesPlaceholderConfigurer() = PropertySourcesPlaceholderConfigurer()
+ }
+ }
+
+ open class Jobs {
+ @Scheduled(cron = "0 0 2 * * *", zone = "Europe/Vienna") open fun syncUsers() {}
+
+ @Scheduled(cron = "\${my.cron.schedule}") open fun fromProperty() {}
+
+ @Scheduled(fixedDelay = 1000) open fun fixedDelay() {}
+
+ @Scheduled(cron = "0 0 2 * * *")
+ open fun oops() {
+ throw IllegalStateException("thrown on purpose")
+ }
+
+ @SentryCheckIn("explicit") @Scheduled(cron = "0 0 2 * * *") open fun explicit() {}
+
+ @SentryCheckIn("explicit-delay") @Scheduled(fixedDelay = 1000) open fun explicitFixedDelay() {}
+
+ @Scheduled(cron = "0 0 2 * * *") open fun excluded() {}
+
+ open fun notScheduled() {}
+
+ @Scheduled(cron = "0 0 2 * * *") open fun reactive(): Mono = Mono.empty()
+ }
+
+ open class CleanupJob {
+ @Scheduled(cron = "0 0 2 * * *") open fun run() {}
+ }
+
+ open class Cleanup {
+ @Scheduled(cron = "0 0 2 * * *") open fun jobRun() {}
+ }
+
+ open class HiddenJobs {
+ @Scheduled(cron = "0 0 2 * * *") private fun hidden() {}
+
+ companion object {
+ @JvmStatic @Scheduled(cron = "0 0 2 * * *") fun static() {}
+ }
+ }
+
+ open class ExcludedJobs {
+ @Scheduled(cron = "0 0 2 * * *") open fun run() {}
+ }
+
+ class FinalJobs {
+ @Scheduled(cron = "0 0 2 * * *") fun run() {}
+ }
+
+ open class ParentJobs {
+ @Scheduled(cron = "0 0 2 * * *") open fun run() {}
+ }
+
+ open class OverridingJobs : ParentJobs() {
+ override fun run() {}
+ }
+}
diff --git a/sentry-spring/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api
index 9073d10e594..33f383a4025 100644
--- a/sentry-spring/api/sentry-spring.api
+++ b/sentry-spring/api/sentry-spring.api
@@ -139,12 +139,14 @@ public final class io/sentry/spring/cache/SentryCacheWrapper : org/springframewo
public abstract interface annotation class io/sentry/spring/checkin/SentryCheckIn : java/lang/annotation/Annotation {
public abstract fun heartbeat ()Z
public abstract fun monitorSlug ()Ljava/lang/String;
+ public abstract fun upsertMonitorConfig ()Z
public abstract fun value ()Ljava/lang/String;
}
public class io/sentry/spring/checkin/SentryCheckInAdvice : org/aopalliance/intercept/MethodInterceptor, org/springframework/context/EmbeddedValueResolverAware {
public fun ()V
public fun (Lio/sentry/IScopes;)V
+ public fun (Lio/sentry/IScopes;Z)V
public fun invoke (Lorg/aopalliance/intercept/MethodInvocation;)Ljava/lang/Object;
public fun setEmbeddedValueResolver (Lorg/springframework/util/StringValueResolver;)V
}
@@ -165,6 +167,11 @@ public class io/sentry/spring/checkin/SentryQuartzConfiguration {
public fun schedulerFactoryBeanCustomizer ()Lorg/springframework/boot/autoconfigure/quartz/SchedulerFactoryBeanCustomizer;
}
+public final class io/sentry/spring/checkin/SentryScheduledCheckInPointcut : org/springframework/aop/support/StaticMethodMatcherPointcut {
+ public fun (Ljava/util/List;)V
+ public fun matches (Ljava/lang/reflect/Method;Ljava/lang/Class;)Z
+}
+
public final class io/sentry/spring/checkin/SentrySchedulerFactoryBeanCustomizer : org/springframework/boot/autoconfigure/quartz/SchedulerFactoryBeanCustomizer {
public fun ()V
public fun customize (Lorg/springframework/scheduling/quartz/SchedulerFactoryBean;)V
diff --git a/sentry-spring/build.gradle.kts b/sentry-spring/build.gradle.kts
index 52ed26cce17..6c6c98b6a7d 100644
--- a/sentry-spring/build.gradle.kts
+++ b/sentry-spring/build.gradle.kts
@@ -10,6 +10,9 @@ plugins {
alias(libs.plugins.errorprone)
alias(libs.plugins.gradle.versions)
alias(libs.plugins.buildconfig)
+ id("io.sentry.spotless")
+ id("io.sentry.jdk-target")
+ id("io.sentry.publish")
id("io.sentry.api-validation")
}
@@ -26,7 +29,7 @@ dependencies {
compileOnly(Config.Libs.springWeb)
compileOnly(Config.Libs.springAop)
compileOnly(Config.Libs.springSecurityWeb)
- compileOnly(Config.Libs.aspectj)
+ compileOnly(libs.aspectjweaver)
compileOnly(Config.Libs.springWebflux)
compileOnly(projects.sentryGraphql)
compileOnly(projects.sentryKafka)
diff --git a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java
index 805f4bd5464..55fedd93c2e 100644
--- a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java
+++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java
@@ -29,6 +29,16 @@
*/
boolean heartbeat() default false;
+ /**
+ * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so
+ * Sentry creates or updates the monitor.
+ *
+ * Heartbeat check-ins never send a monitor config.
+ *
+ * @return true to send a monitor config, false by default
+ */
+ boolean upsertMonitorConfig() default false;
+
/**
* Monitor slug. If not set, no check-in will be sent.
*
diff --git a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java
index 2abd2cfb994..f6923c77e6e 100644
--- a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java
+++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java
@@ -6,13 +6,25 @@
import io.sentry.DateUtils;
import io.sentry.IScopes;
import io.sentry.ISentryLifecycleToken;
+import io.sentry.MonitorConfig;
import io.sentry.ScopesAdapter;
import io.sentry.SentryLevel;
import io.sentry.protocol.SentryId;
import io.sentry.time.Stopwatch;
+import io.sentry.util.MonitorConfigUtils;
import io.sentry.util.Objects;
import io.sentry.util.TracingUtils;
import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.format.DateTimeParseException;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+import java.util.zip.CRC32;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.jetbrains.annotations.ApiStatus;
@@ -20,7 +32,11 @@
import org.jetbrains.annotations.Nullable;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.EmbeddedValueResolverAware;
+import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.scheduling.annotation.Schedules;
+import org.springframework.util.ClassUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringValueResolver;
@@ -31,16 +47,44 @@
@ApiStatus.Internal
@Open
public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueResolverAware {
+ // Spring before 5.3 parses crons with CronSequenceGenerator
+ private static final boolean LEGACY_CRON_PARSER =
+ !ClassUtils.isPresent(
+ "org.springframework.scheduling.support.CronExpression",
+ SentryCheckInAdvice.class.getClassLoader());
+
+ private static final @NotNull Pattern WORD_BOUNDARY =
+ Pattern.compile("(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])");
+ private static final int MAX_SLUG_LENGTH = 50;
+
private final @NotNull IScopes scopes;
+ private final boolean monitorScheduledTasks;
+
private @Nullable StringValueResolver resolver;
+ private final @NotNull Map monitorConfigs =
+ new ConcurrentHashMap<>();
+
+ private final @NotNull Map derivedSlugOwners = new ConcurrentHashMap<>();
+
+ private final @NotNull Set collidingSlugs = ConcurrentHashMap.newKeySet();
+
public SentryCheckInAdvice() {
this(ScopesAdapter.getInstance());
}
public SentryCheckInAdvice(final @NotNull IScopes scopes) {
+ this(scopes, false);
+ }
+
+ /**
+ * @param monitorScheduledTasks whether to also monitor {@code @Scheduled} methods without {@link
+ * SentryCheckIn}, using a slug derived from the class and method name
+ */
+ public SentryCheckInAdvice(final @NotNull IScopes scopes, final boolean monitorScheduledTasks) {
this.scopes = Objects.requireNonNull(scopes, "scopes are required");
+ this.monitorScheduledTasks = monitorScheduledTasks;
}
@Override
@@ -52,6 +96,24 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl
SentryCheckIn checkInAnnotation =
AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class);
if (checkInAnnotation == null) {
+ if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) {
+ final @NotNull Class> targetClass = invocation.getThis().getClass();
+ final @NotNull String taskName = scheduledTaskName(targetClass, mostSpecificMethod);
+ final @Nullable MonitorConfig monitorConfig = monitorConfig(mostSpecificMethod);
+ if (monitorConfig == null) {
+ scopes
+ .getOptions()
+ .getLogger()
+ .log(
+ SentryLevel.DEBUG,
+ "Not monitoring %s because Sentry Crons can't express its @Scheduled schedule.",
+ taskName);
+ return invocation.proceed();
+ }
+ final @NotNull String monitorSlug = scheduledTaskSlug(taskName);
+ warnOnSlugCollision(monitorSlug, targetClass, mostSpecificMethod);
+ return captureCheckIns(invocation, monitorSlug, false, monitorConfig);
+ }
return invocation.proceed();
}
@@ -90,6 +152,19 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl
return invocation.proceed();
}
+ final @Nullable MonitorConfig monitorConfig =
+ !isHeartbeatOnly && checkInAnnotation.upsertMonitorConfig()
+ ? monitorConfig(mostSpecificMethod)
+ : null;
+ return captureCheckIns(invocation, monitorSlug, isHeartbeatOnly, monitorConfig);
+ }
+
+ private Object captureCheckIns(
+ final @NotNull MethodInvocation invocation,
+ final @NotNull String monitorSlug,
+ final boolean isHeartbeatOnly,
+ final @Nullable MonitorConfig monitorConfig)
+ throws Throwable {
try (final @NotNull ISentryLifecycleToken ignored =
scopes.forkedScopes("SentryCheckInAdvice").makeCurrent()) {
TracingUtils.startNewTrace(scopes);
@@ -101,7 +176,9 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl
try {
if (!isHeartbeatOnly) {
- checkInId = scopes.captureCheckIn(new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS));
+ final @NotNull CheckIn inProgress = new CheckIn(monitorSlug, CheckInStatus.IN_PROGRESS);
+ inProgress.setMonitorConfig(monitorConfig);
+ checkInId = scopes.captureCheckIn(inProgress);
}
return invocation.proceed();
} catch (Throwable e) {
@@ -116,6 +193,146 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl
}
}
+ private void warnOnSlugCollision(
+ final @NotNull String monitorSlug,
+ final @NotNull Class> targetClass,
+ final @NotNull Method method) {
+ final @NotNull String identity =
+ ClassUtils.getUserClass(targetClass).getName() + "." + method.getName();
+ final @Nullable String owner = derivedSlugOwners.putIfAbsent(monitorSlug, identity);
+ if (owner != null && !owner.equals(identity) && collidingSlugs.add(monitorSlug)) {
+ scopes
+ .getOptions()
+ .getLogger()
+ .log(
+ SentryLevel.WARNING,
+ "Monitor slug \"%s\" is used by both %s and %s, so their check-ins go to one monitor. Add @SentryCheckIn(\"your-slug\") to one of them.",
+ monitorSlug,
+ owner,
+ identity);
+ }
+ }
+
+ static boolean isScheduled(final @NotNull Method method) {
+ return !AnnotatedElementUtils.getMergedRepeatableAnnotations(
+ method, Scheduled.class, Schedules.class)
+ .isEmpty();
+ }
+
+ /** {@code ClassName.methodName}, with nested classes as {@code Outer.Inner}. */
+ static @NotNull String scheduledTaskName(
+ final @NotNull Class> targetClass, final @NotNull Method method) {
+ return ClassUtils.getShortName(ClassUtils.getUserClass(targetClass)) + "." + method.getName();
+ }
+
+ static @NotNull String scheduledTaskSlug(final @NotNull String taskName) {
+ final @NotNull String slug =
+ WORD_BOUNDARY
+ .matcher(taskName)
+ .replaceAll("-")
+ .toLowerCase(Locale.ROOT)
+ .replaceAll("[^a-z0-9]+", "-")
+ .replaceAll("^-+|-+$", "");
+ if (slug.length() <= MAX_SLUG_LENGTH) {
+ return slug;
+ }
+ // keep truncated slugs unique
+ final @NotNull CRC32 crc = new CRC32();
+ crc.update(slug.getBytes(StandardCharsets.UTF_8));
+ final @NotNull String hash = String.format(Locale.ROOT, "%08x", crc.getValue());
+ return slug.substring(0, MAX_SLUG_LENGTH - hash.length() - 1).replaceAll("-+$", "")
+ + "-"
+ + hash;
+ }
+
+ private @Nullable MonitorConfig monitorConfig(final @NotNull Method method) {
+ return monitorConfigs.computeIfAbsent(
+ method, key -> new CachedMonitorConfig(monitorConfigFromScheduled(key)))
+ .config;
+ }
+
+ private @Nullable MonitorConfig monitorConfigFromScheduled(final @NotNull Method method) {
+ try {
+ final @NotNull Set schedules =
+ AnnotatedElementUtils.getMergedRepeatableAnnotations(
+ method, Scheduled.class, Schedules.class);
+ if (schedules.size() != 1) {
+ return null;
+ }
+ final @NotNull Scheduled scheduled = schedules.iterator().next();
+ // timeUnit only exists from Spring 5.3.10, so read it as an attribute
+ final @Nullable Object timeUnitAttribute =
+ AnnotationUtils.getAnnotationAttributes(scheduled).get("timeUnit");
+ final @NotNull TimeUnit timeUnit =
+ timeUnitAttribute instanceof TimeUnit
+ ? (TimeUnit) timeUnitAttribute
+ : TimeUnit.MILLISECONDS;
+ final @Nullable String cron = resolve(scheduled.cron());
+ // Spring only reads the zone for a cron
+ final @Nullable String zone =
+ cron == null || cron.isEmpty() ? null : resolve(scheduled.zone());
+ return MonitorConfigUtils.fromSpringScheduled(
+ cron,
+ zone,
+ periodMillis(scheduled.fixedRate(), scheduled.fixedRateString(), timeUnit),
+ periodMillis(scheduled.fixedDelay(), scheduled.fixedDelayString(), timeUnit),
+ LEGACY_CRON_PARSER);
+ } catch (RuntimeException e) {
+ scopes
+ .getOptions()
+ .getLogger()
+ .log(SentryLevel.WARNING, "Could not derive a monitor config from @Scheduled.", e);
+ return null;
+ }
+ }
+
+ private @Nullable Long periodMillis(
+ final long value, final @NotNull String valueString, final @NotNull TimeUnit timeUnit) {
+ if (value >= 0) {
+ return timeUnit.toMillis(value);
+ }
+ final @Nullable String resolved = resolve(valueString);
+ if (resolved == null || resolved.isEmpty()) {
+ return null;
+ }
+ final @NotNull String trimmed = resolved.trim();
+ @Nullable Long millis = null;
+ if (trimmed.startsWith("P") || trimmed.startsWith("p")) {
+ try {
+ millis = Duration.parse(trimmed).toMillis();
+ } catch (DateTimeParseException | ArithmeticException e) {
+ // logged below
+ }
+ } else {
+ millis = MonitorConfigUtils.parsePeriodMillis(trimmed, timeUnit);
+ }
+ if (millis == null) {
+ scopes
+ .getOptions()
+ .getLogger()
+ .log(
+ SentryLevel.DEBUG,
+ "Not sending a monitor config because the @Scheduled period '%s' could not be parsed.",
+ trimmed);
+ }
+ return millis;
+ }
+
+ private @Nullable String resolve(final @NotNull String value) {
+ if (resolver == null || value.isEmpty()) {
+ return value;
+ }
+ return resolver.resolveStringValue(value);
+ }
+
+ private static final class CachedMonitorConfig {
+ private final @Nullable MonitorConfig config;
+
+ private CachedMonitorConfig(final @Nullable MonitorConfig config) {
+ this.config = config;
+ }
+ }
+
@Override
public void setEmbeddedValueResolver(StringValueResolver resolver) {
this.resolver = resolver;
diff --git a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryScheduledCheckInPointcut.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryScheduledCheckInPointcut.java
new file mode 100644
index 00000000000..16412849f61
--- /dev/null
+++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryScheduledCheckInPointcut.java
@@ -0,0 +1,66 @@
+package io.sentry.spring.checkin;
+
+import io.sentry.FilterString;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.List;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.springframework.aop.support.AopUtils;
+import org.springframework.aop.support.StaticMethodMatcherPointcut;
+import org.springframework.core.ReactiveAdapterRegistry;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Matches {@code @Scheduled} methods without {@link SentryCheckIn}, except excluded ones. Excludes
+ * match the task name ({@code ClassName.methodName}) exactly or as a regex.
+ */
+@ApiStatus.Internal
+public final class SentryScheduledCheckInPointcut extends StaticMethodMatcherPointcut {
+ private final @NotNull List excludedScheduledTasks = new ArrayList<>();
+
+ public SentryScheduledCheckInPointcut(final @Nullable List excludedScheduledTasks) {
+ if (excludedScheduledTasks != null) {
+ for (final @NotNull String task : excludedScheduledTasks) {
+ if (!task.trim().isEmpty()) {
+ this.excludedScheduledTasks.add(new FilterString(task.trim()));
+ }
+ }
+ }
+ }
+
+ @Override
+ public boolean matches(final @NotNull Method method, final @NotNull Class> targetClass) {
+ final @NotNull Class> userClass = ClassUtils.getUserClass(targetClass);
+ // CGLIB can't intercept these, and Spring calls private and static methods on the target
+ final int modifiers = method.getModifiers();
+ if (Modifier.isFinal(userClass.getModifiers())
+ || Modifier.isFinal(modifiers)
+ || Modifier.isPrivate(modifiers)
+ || Modifier.isStatic(modifiers)) {
+ return false;
+ }
+ final @NotNull Method specificMethod = AopUtils.getMostSpecificMethod(method, userClass);
+ if (!SentryCheckInAdvice.isScheduled(specificMethod)
+ || AnnotationUtils.findAnnotation(specificMethod, SentryCheckIn.class) != null) {
+ return false;
+ }
+ // Spring calls reactive and suspend methods once and subscribes to the result on each run
+ if (specificMethod.getParameterCount() > 0
+ || ReactiveAdapterRegistry.getSharedInstance().getAdapter(specificMethod.getReturnType())
+ != null) {
+ return false;
+ }
+ final @NotNull String taskName =
+ SentryCheckInAdvice.scheduledTaskName(userClass, specificMethod);
+ for (final @NotNull FilterString excluded : excludedScheduledTasks) {
+ if (excluded.getFilterString().equals(taskName) || excluded.matches(taskName)) {
+ return false;
+ }
+ }
+ return true;
+ }
+}
diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt
index 2c5e7d0cd06..0bac0df528c 100644
--- a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt
+++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt
@@ -4,17 +4,22 @@ import io.sentry.CheckIn
import io.sentry.CheckInStatus
import io.sentry.IScopes
import io.sentry.ISentryLifecycleToken
+import io.sentry.MonitorConfig
import io.sentry.Sentry
import io.sentry.SentryOptions
import io.sentry.protocol.SentryId
import io.sentry.spring.checkin.SentryCheckIn
import io.sentry.spring.checkin.SentryCheckInAdviceConfiguration
import io.sentry.spring.checkin.SentryCheckInPointcutConfiguration
+import java.util.TimeZone
+import java.util.concurrent.TimeUnit
import kotlin.RuntimeException
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertSame
import org.junit.jupiter.api.assertThrows
import org.junit.runner.RunWith
import org.mockito.kotlin.any
@@ -33,6 +38,8 @@ import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.EnableAspectJAutoProxy
import org.springframework.context.annotation.Import
import org.springframework.context.support.PropertySourcesPlaceholderConfigurer
+import org.springframework.scheduling.annotation.Scheduled
+import org.springframework.scheduling.annotation.Schedules
import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig
import org.springframework.test.context.junit4.SpringRunner
@@ -40,7 +47,15 @@ import org.springframework.util.StringValueResolver
@RunWith(SpringRunner::class)
@SpringJUnitConfig(SentryCheckInAdviceTest.Config::class)
-@TestPropertySource(properties = ["my.cron.slug = mypropertycronslug"])
+@TestPropertySource(
+ properties =
+ [
+ "my.cron.slug = mypropertycronslug",
+ "my.cron.schedule = 0 30 2 * * *",
+ "my.cron.zone = America/New_York",
+ "my.cron.empty.zone = ",
+ ]
+)
class SentryCheckInAdviceTest {
@Autowired lateinit var sampleService: SampleService
@@ -51,6 +66,10 @@ class SentryCheckInAdviceTest {
@Autowired lateinit var sampleServiceSpringProperties: SampleServiceSpringProperties
+ @Autowired lateinit var sampleServiceScheduled: SampleServiceScheduled
+
+ @Autowired lateinit var sampleServiceInheritedSchedule: SampleServiceInheritedSchedule
+
@Autowired lateinit var scopes: IScopes
val lifecycleToken = mock()
@@ -75,6 +94,7 @@ class SentryCheckInAdviceTest {
val inProgressCheckIn = checkInCaptor.firstValue
assertEquals("monitor_slug_1", inProgressCheckIn.monitorSlug)
assertEquals(CheckInStatus.IN_PROGRESS.apiName(), inProgressCheckIn.status)
+ assertNull(inProgressCheckIn.monitorConfig)
val doneCheckIn = checkInCaptor.lastValue
assertEquals("monitor_slug_1", doneCheckIn.monitorSlug)
@@ -229,6 +249,160 @@ class SentryCheckInAdviceTest {
order.verify(lifecycleToken).close()
}
+ @Test
+ fun `cron with zone is sent as crontab monitor config`() {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZone() }
+ assertNotNull(config)
+ assertEquals("crontab", config.schedule.type)
+ assertEquals("15 10 * * 1-5", config.schedule.value)
+ assertNull(config.schedule.unit)
+ assertEquals("Europe/Vienna", config.timezone)
+ }
+
+ @Test
+ fun `cron without zone is sent in the JVM default zone`() {
+ val defaultTimeZone = TimeZone.getDefault()
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo"))
+ try {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.cron() }
+ assertEquals("0 2 * * *", config?.schedule?.value)
+ assertEquals("Asia/Tokyo", config?.timezone)
+ } finally {
+ TimeZone.setDefault(defaultTimeZone)
+ }
+ }
+
+ @Test
+ fun `cron and zone placeholders are resolved`() {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.cronFromProperties() }
+ assertEquals("30 2 * * *", config?.schedule?.value)
+ assertEquals("America/New_York", config?.timezone)
+ }
+
+ @Test
+ fun `fixed rate with time unit is sent as interval monitor config`() {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() }
+ assertNotNull(config)
+ assertEquals("interval", config.schedule.type)
+ assertEquals("2", config.schedule.value)
+ assertEquals("hour", config.schedule.unit)
+ }
+
+ @Test
+ fun `ISO-8601 fixed rate string is sent as interval monitor config`() {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateIso() }
+ assertEquals("10", config?.schedule?.value)
+ assertEquals("minute", config?.schedule?.unit)
+ }
+
+ @Test
+ fun `simple duration fixed rate string is sent as interval monitor config`() {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.fixedRateSimpleDuration() }
+ assertEquals("interval", config?.schedule?.type)
+ assertEquals("5", config?.schedule?.value)
+ assertEquals("minute", config?.schedule?.unit)
+ }
+
+ @Test
+ fun `fixed delay sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelay() })
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.fixedDelayIso() })
+ }
+
+ @Test
+ fun `disabled cron sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.disabledCron() })
+ }
+
+ @Test
+ fun `zone placeholder resolving to empty uses the JVM default zone`() {
+ val defaultTimeZone = TimeZone.getDefault()
+ TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo"))
+ try {
+ val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithEmptyZone() }
+ assertEquals("0 3 * * *", config?.schedule?.value)
+ assertEquals("Asia/Tokyo", config?.timezone)
+ } finally {
+ TimeZone.setDefault(defaultTimeZone)
+ }
+ }
+
+ @Test
+ fun `cron zone overrides the default timezone from options`() {
+ val options =
+ SentryOptions().apply {
+ cron = SentryOptions.Cron().apply { defaultTimezone = "Europe/Berlin" }
+ }
+ whenever(scopes.options).thenReturn(options)
+ val config = inProgressMonitorConfig { sampleServiceScheduled.cronWithZoneAndDefaults() }
+ assertEquals("Europe/Vienna", config?.timezone)
+ }
+
+ @Test
+ fun `unresolvable cron placeholder sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.unresolvableCron() })
+ }
+
+ @Test
+ fun `exception while deriving monitor config sends no monitor config and runs the job`() {
+ var result = 0
+ assertNull(inProgressMonitorConfig { result = sampleServiceScheduled.derivationThrows() })
+ assertEquals(1, result)
+ }
+
+ @Test
+ fun `monitor config is derived once per method`() {
+ val first = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() }
+ val second = inProgressMonitorConfig { sampleServiceScheduled.fixedRateHours() }
+ assertNotNull(first)
+ assertSame(first, second)
+ }
+
+ @Test
+ fun `multiple schedules send no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.multipleSchedules() })
+ }
+
+ @Test
+ fun `schedules container sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.schedulesContainer() })
+ }
+
+ @Test
+ fun `upsertMonitorConfig false sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDisabled() })
+ }
+
+ @Test
+ fun `upsertMonitorConfig defaults to false`() {
+ assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDefault() })
+ }
+
+ @Test
+ fun `@Scheduled on an overridden parent method sends no monitor config`() {
+ assertNull(inProgressMonitorConfig { sampleServiceInheritedSchedule.job() })
+ }
+
+ @Test
+ fun `heartbeat with @Scheduled sends a single check-in without monitor config`() {
+ val checkInCaptor = argumentCaptor()
+ whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId())
+ sampleServiceScheduled.heartbeat()
+ assertEquals(1, checkInCaptor.allValues.size)
+ assertEquals(CheckInStatus.OK.apiName(), checkInCaptor.firstValue.status)
+ assertNull(checkInCaptor.firstValue.monitorConfig)
+ }
+
+ private fun inProgressMonitorConfig(block: () -> Unit): MonitorConfig? {
+ val checkInCaptor = argumentCaptor()
+ whenever(scopes.captureCheckIn(checkInCaptor.capture())).thenReturn(SentryId())
+ block()
+ assertEquals(2, checkInCaptor.allValues.size)
+ assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkInCaptor.firstValue.status)
+ assertNull(checkInCaptor.lastValue.monitorConfig)
+ return checkInCaptor.firstValue.monitorConfig
+ }
+
@Configuration
@EnableAspectJAutoProxy(proxyTargetClass = true)
@Import(SentryCheckInAdviceConfiguration::class, SentryCheckInPointcutConfiguration::class)
@@ -242,6 +416,10 @@ class SentryCheckInAdviceTest {
@Bean open fun sampleServiceSpringProperties() = SampleServiceSpringProperties()
+ @Bean open fun sampleServiceScheduled() = SampleServiceScheduled()
+
+ @Bean open fun sampleServiceInheritedSchedule() = SampleServiceInheritedSchedule()
+
@Bean
open fun scopes(): IScopes {
val scopes = mock()
@@ -292,6 +470,88 @@ class SentryCheckInAdviceTest {
open fun helloExceptionProperty() = 1
}
+ open class SampleServiceScheduled {
+
+ @SentryCheckIn("cron_zone", upsertMonitorConfig = true)
+ @Scheduled(cron = "0 15 10 * * MON-FRI", zone = "Europe/Vienna")
+ open fun cronWithZone() {}
+
+ @SentryCheckIn("cron", upsertMonitorConfig = true)
+ @Scheduled(cron = "0 0 2 * * *")
+ open fun cron() {}
+
+ @SentryCheckIn("cron_properties", upsertMonitorConfig = true)
+ @Scheduled(cron = "\${my.cron.schedule}", zone = "\${my.cron.zone}")
+ open fun cronFromProperties() {}
+
+ @SentryCheckIn("fixed_rate_hours", upsertMonitorConfig = true)
+ @Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS)
+ open fun fixedRateHours() {}
+
+ @SentryCheckIn("fixed_rate_iso", upsertMonitorConfig = true)
+ @Scheduled(fixedRateString = "PT10M")
+ open fun fixedRateIso() {}
+
+ @SentryCheckIn("fixed_rate_simple", upsertMonitorConfig = true)
+ @Scheduled(fixedRateString = "5m")
+ open fun fixedRateSimpleDuration() {}
+
+ @SentryCheckIn("fixed_delay", upsertMonitorConfig = true)
+ @Scheduled(fixedDelay = 300_000)
+ open fun fixedDelay() {}
+
+ @SentryCheckIn("fixed_delay_iso", upsertMonitorConfig = true)
+ @Scheduled(fixedDelayString = "PT10M")
+ open fun fixedDelayIso() {}
+
+ @SentryCheckIn("disabled_cron", upsertMonitorConfig = true)
+ @Scheduled(cron = "-")
+ open fun disabledCron() {}
+
+ @SentryCheckIn("cron_empty_zone", upsertMonitorConfig = true)
+ @Scheduled(cron = "0 0 3 * * *", zone = "\${my.cron.empty.zone}")
+ open fun cronWithEmptyZone() {}
+
+ @SentryCheckIn("cron_zone_defaults", upsertMonitorConfig = true)
+ @Scheduled(cron = "0 0 4 * * *", zone = "Europe/Vienna")
+ open fun cronWithZoneAndDefaults() {}
+
+ @SentryCheckIn("schedules_container", upsertMonitorConfig = true)
+ @Schedules(Scheduled(cron = "0 0 1 * * *"), Scheduled(cron = "0 0 13 * * *"))
+ open fun schedulesContainer() {}
+
+ @SentryCheckIn("unresolvable_cron", upsertMonitorConfig = true)
+ @Scheduled(cron = "\${my.cron.missing}")
+ open fun unresolvableCron() {}
+
+ @SentryCheckIn("derivation_throws", upsertMonitorConfig = true)
+ @Scheduled(cron = "\${my.cron.exception.property}")
+ open fun derivationThrows() = 1
+
+ @SentryCheckIn("multiple", upsertMonitorConfig = true)
+ @Scheduled(cron = "0 0 1 * * *")
+ @Scheduled(cron = "0 0 13 * * *")
+ open fun multipleSchedules() {}
+
+ @SentryCheckIn("upsert_disabled", upsertMonitorConfig = false)
+ @Scheduled(cron = "0 0 1 * * *")
+ open fun upsertDisabled() {}
+
+ @SentryCheckIn("upsert_default") @Scheduled(cron = "0 0 1 * * *") open fun upsertDefault() {}
+
+ @SentryCheckIn("heartbeat", heartbeat = true, upsertMonitorConfig = true)
+ @Scheduled(cron = "0 0 1 * * *")
+ open fun heartbeat() {}
+ }
+
+ open class ScheduledParent {
+ @Scheduled(cron = "0 0 1 * * *") open fun job() {}
+ }
+
+ open class SampleServiceInheritedSchedule : ScheduledParent() {
+ @SentryCheckIn("inherited", upsertMonitorConfig = true) override fun job() {}
+ }
+
class MyPropertyPlaceholderConfigurer : PropertySourcesPlaceholderConfigurer() {
override fun doProcessProperties(
diff --git a/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt
new file mode 100644
index 00000000000..1af18fe95f0
--- /dev/null
+++ b/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt
@@ -0,0 +1,365 @@
+package io.sentry.spring.checkin
+
+import io.sentry.CheckIn
+import io.sentry.CheckInStatus
+import io.sentry.ILogger
+import io.sentry.IScopes
+import io.sentry.ISentryLifecycleToken
+import io.sentry.SentryLevel
+import io.sentry.SentryOptions
+import io.sentry.protocol.SentryId
+import java.lang.reflect.Modifier
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import org.junit.jupiter.api.assertThrows
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.argumentCaptor
+import org.mockito.kotlin.atLeast
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.reset
+import org.mockito.kotlin.times
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.springframework.aop.Advisor
+import org.springframework.aop.Pointcut
+import org.springframework.aop.framework.ProxyFactory
+import org.springframework.aop.support.AopUtils
+import org.springframework.aop.support.DefaultPointcutAdvisor
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.beans.factory.annotation.Qualifier
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.context.annotation.EnableAspectJAutoProxy
+import org.springframework.context.annotation.Import
+import org.springframework.context.support.PropertySourcesPlaceholderConfigurer
+import org.springframework.scheduling.annotation.Scheduled
+import org.springframework.test.context.TestPropertySource
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig
+import org.springframework.test.context.junit4.SpringRunner
+import reactor.core.publisher.Mono
+
+@RunWith(SpringRunner::class)
+@SpringJUnitConfig(SentryScheduledCheckInTest.Config::class)
+@TestPropertySource(properties = ["my.cron.schedule = 0 30 2 * * *"])
+class SentryScheduledCheckInTest {
+ private val SLUG = "sentry-scheduled-check-in-test-jobs-sync-users"
+ private val COLLISION_WARNING =
+ "Monitor slug \"%s\" is used by both %s and %s, so their check-ins go to one monitor. Add @SentryCheckIn(\"your-slug\") to one of them."
+
+ @Autowired lateinit var jobs: Jobs
+
+ @Autowired lateinit var excludedJobs: ExcludedJobs
+
+ @Autowired lateinit var finalJobs: FinalJobs
+
+ @Autowired lateinit var overridingJobs: OverridingJobs
+
+ @Autowired lateinit var scopes: IScopes
+
+ @BeforeTest
+ fun setup() {
+ reset(scopes)
+ whenever(scopes.options).thenReturn(SentryOptions())
+ whenever(scopes.forkedScopes(any())).thenReturn(scopes)
+ whenever(scopes.makeCurrent()).thenReturn(mock())
+ whenever(scopes.captureCheckIn(any())).thenReturn(SentryId())
+ }
+
+ @Test
+ fun `scheduled method without @SentryCheckIn is monitored with a derived slug and config`() {
+ val checkIns = capture { jobs.syncUsers() }
+ assertEquals(2, checkIns.size)
+ assertEquals(SLUG, checkIns[0].monitorSlug)
+ assertEquals(CheckInStatus.IN_PROGRESS.apiName(), checkIns[0].status)
+ val config = assertNotNull(checkIns[0].monitorConfig)
+ assertEquals("0 2 * * *", config.schedule.value)
+ assertEquals("Europe/Vienna", config.timezone)
+ assertEquals(SLUG, checkIns[1].monitorSlug)
+ assertEquals(CheckInStatus.OK.apiName(), checkIns[1].status)
+ }
+
+ @Test
+ fun `cron placeholder is resolved`() {
+ val checkIns = capture { jobs.fromProperty() }
+ assertEquals("30 2 * * *", checkIns[0].monitorConfig!!.schedule.value)
+ }
+
+ @Test
+ fun `tasks with unsupported schedules are not monitored`() {
+ jobs.fixedDelay()
+ verify(scopes, never()).captureCheckIn(any())
+ }
+
+ @Test
+ fun `@SentryCheckIn with an unsupported schedule checks in without config`() {
+ val checkIns = capture { jobs.explicitFixedDelay() }
+ assertEquals(listOf("explicit-delay", "explicit-delay"), checkIns.map { it.monitorSlug })
+ assertNull(checkIns[0].monitorConfig)
+ }
+
+ @Test
+ fun `failing job sends an error check-in and rethrows`() {
+ val checkIns = capture { assertThrows { jobs.oops() } }
+ assertEquals(CheckInStatus.ERROR.apiName(), checkIns[1].status)
+ }
+
+ @Test
+ fun `@SentryCheckIn keeps its slug and is only checked in once`() {
+ val checkIns = capture { jobs.explicit() }
+ assertEquals(listOf("explicit", "explicit"), checkIns.map { it.monitorSlug })
+ assertNull(checkIns[0].monitorConfig)
+ }
+
+ @Test
+ fun `methods without @Scheduled are not monitored`() {
+ jobs.notScheduled()
+ verify(scopes, never()).captureCheckIn(any())
+ }
+
+ @Test
+ fun `overriding methods without @Scheduled are not monitored`() {
+ overridingJobs.run()
+ verify(scopes, never()).captureCheckIn(any())
+ }
+
+ @Test
+ fun `reactive methods are not monitored`() {
+ jobs.reactive().block()
+ verify(scopes, never()).captureCheckIn(any())
+ }
+
+ @Test
+ fun `excluded tasks are not monitored`() {
+ jobs.excluded()
+ excludedJobs.run()
+ verify(scopes, never()).captureCheckIn(any())
+ assertFalse(AopUtils.isAopProxy(excludedJobs))
+ }
+
+ @Test
+ fun `final classes are not proxied`() {
+ finalJobs.run()
+ verify(scopes, never()).captureCheckIn(any())
+ assertFalse(AopUtils.isAopProxy(finalJobs))
+ assertTrue(AopUtils.isAopProxy(jobs))
+ }
+
+ @Test
+ fun `private and static methods are not matched`() {
+ val pointcut = SentryScheduledCheckInPointcut(null)
+ val privateMethod = HiddenJobs::class.java.getDeclaredMethod("hidden")
+ val staticMethod = HiddenJobs::class.java.getDeclaredMethod("static")
+ assertTrue(Modifier.isPrivate(privateMethod.modifiers))
+ assertTrue(Modifier.isStatic(staticMethod.modifiers))
+ assertFalse(pointcut.matches(privateMethod, HiddenJobs::class.java))
+ assertFalse(pointcut.matches(staticMethod, HiddenJobs::class.java))
+ assertTrue(pointcut.matches(Jobs::class.java.getMethod("syncUsers"), Jobs::class.java))
+ }
+
+ @Test
+ fun `slugs are kebab case and at most 50 characters`() {
+ assertEquals(
+ "report-jobs-send-daily-report",
+ SentryCheckInAdvice.scheduledTaskSlug("ReportJobs.sendDailyReport"),
+ )
+ assertEquals(
+ "http-client-jobs-run",
+ SentryCheckInAdvice.scheduledTaskSlug("HTTPClientJobs.run"),
+ )
+ assertEquals("outer-inner-run2-fa", SentryCheckInAdvice.scheduledTaskSlug("Outer.Inner.run2FA"))
+ val first =
+ SentryCheckInAdvice.scheduledTaskSlug(
+ "AVeryLongScheduledJobClassNameForReporting.firstMethod"
+ )
+ val second =
+ SentryCheckInAdvice.scheduledTaskSlug(
+ "AVeryLongScheduledJobClassNameForReporting.secondMethod"
+ )
+ assertTrue(first.length <= 50)
+ assertTrue(first.startsWith("a-very-long-scheduled-job-class-name-for-"))
+ assertTrue(first != second)
+ assertEquals(
+ first,
+ SentryCheckInAdvice.scheduledTaskSlug(
+ "AVeryLongScheduledJobClassNameForReporting.firstMethod"
+ ),
+ )
+ }
+
+ @Test
+ fun `task name uses the user class`() {
+ assertEquals(
+ "SentryScheduledCheckInTest.Jobs.syncUsers",
+ SentryCheckInAdvice.scheduledTaskName(
+ jobs.javaClass,
+ Jobs::class.java.getMethod("syncUsers"),
+ ),
+ )
+ }
+
+ @Test
+ fun `warns once when two methods derive the same slug`() {
+ val logger = mockLogger()
+ val advice = SentryCheckInAdvice(scopes, true)
+ val cleanupJob = proxy(CleanupJob(), advice)
+ val cleanup = proxy(Cleanup(), advice)
+
+ cleanupJob.run()
+ cleanup.jobRun()
+ cleanup.jobRun()
+ cleanupJob.run()
+
+ verify(logger, times(1))
+ .log(
+ eq(SentryLevel.WARNING),
+ eq(COLLISION_WARNING),
+ eq("sentry-scheduled-check-in-test-cleanup-job-run"),
+ eq("${CleanupJob::class.java.name}.run"),
+ eq("${Cleanup::class.java.name}.jobRun"),
+ )
+ }
+
+ @Test
+ fun `does not warn when derived slugs differ or the same method runs again`() {
+ val logger = mockLogger()
+ val advice = SentryCheckInAdvice(scopes, true)
+ val cleanupJob = proxy(CleanupJob(), advice)
+ val otherJobs = proxy(Jobs(), advice)
+
+ cleanupJob.run()
+ cleanupJob.run()
+ otherJobs.syncUsers()
+
+ verify(logger, never()).log(eq(SentryLevel.WARNING), eq(COLLISION_WARNING), any(), any(), any())
+ }
+
+ private fun mockLogger(): ILogger {
+ val logger = mock()
+ val options =
+ SentryOptions().apply {
+ isDebug = true
+ setLogger(logger)
+ }
+ whenever(scopes.options).thenReturn(options)
+ return logger
+ }
+
+ private fun proxy(target: T, advice: SentryCheckInAdvice): T {
+ val factory = ProxyFactory(target)
+ factory.isProxyTargetClass = true
+ factory.addAdvice(advice)
+ @Suppress("UNCHECKED_CAST")
+ return factory.proxy as T
+ }
+
+ private fun capture(block: () -> Unit): List {
+ val captor = argumentCaptor()
+ block()
+ verify(scopes, atLeast(0)).captureCheckIn(captor.capture())
+ return captor.allValues
+ }
+
+ @Configuration
+ @EnableAspectJAutoProxy(proxyTargetClass = true)
+ @Import(SentryCheckInPointcutConfiguration::class)
+ open class Config {
+
+ @Bean open fun jobs() = Jobs()
+
+ @Bean open fun excludedJobs() = ExcludedJobs()
+
+ @Bean open fun finalJobs() = FinalJobs()
+
+ @Bean open fun overridingJobs() = OverridingJobs()
+
+ @Bean open fun scopes(): IScopes = mock()
+
+ @Bean
+ open fun sentryCheckInAdvisor(
+ @Qualifier("sentryCheckInPointcut") pointcut: Pointcut,
+ scopes: IScopes,
+ ): Advisor = DefaultPointcutAdvisor(pointcut, SentryCheckInAdvice(scopes))
+
+ @Bean open fun sentryScheduledCheckInAdvice(scopes: IScopes) = SentryCheckInAdvice(scopes, true)
+
+ @Bean
+ open fun sentryScheduledCheckInAdvisor(
+ @Qualifier("sentryScheduledCheckInAdvice") advice: SentryCheckInAdvice
+ ): Advisor =
+ DefaultPointcutAdvisor(
+ SentryScheduledCheckInPointcut(
+ listOf("SentryScheduledCheckInTest.Jobs.excluded", ".*\\.ExcludedJobs\\..*", " ")
+ ),
+ advice,
+ )
+
+ companion object {
+ @Bean
+ @JvmStatic
+ fun propertySourcesPlaceholderConfigurer() = PropertySourcesPlaceholderConfigurer()
+ }
+ }
+
+ open class Jobs {
+ @Scheduled(cron = "0 0 2 * * *", zone = "Europe/Vienna") open fun syncUsers() {}
+
+ @Scheduled(cron = "\${my.cron.schedule}") open fun fromProperty() {}
+
+ @Scheduled(fixedDelay = 1000) open fun fixedDelay() {}
+
+ @Scheduled(cron = "0 0 2 * * *")
+ open fun oops() {
+ throw IllegalStateException("thrown on purpose")
+ }
+
+ @SentryCheckIn("explicit") @Scheduled(cron = "0 0 2 * * *") open fun explicit() {}
+
+ @SentryCheckIn("explicit-delay") @Scheduled(fixedDelay = 1000) open fun explicitFixedDelay() {}
+
+ @Scheduled(cron = "0 0 2 * * *") open fun excluded() {}
+
+ open fun notScheduled() {}
+
+ @Scheduled(cron = "0 0 2 * * *") open fun reactive(): Mono = Mono.empty()
+ }
+
+ open class CleanupJob {
+ @Scheduled(cron = "0 0 2 * * *") open fun run() {}
+ }
+
+ open class Cleanup {
+ @Scheduled(cron = "0 0 2 * * *") open fun jobRun() {}
+ }
+
+ open class HiddenJobs {
+ @Scheduled(cron = "0 0 2 * * *") private fun hidden() {}
+
+ companion object {
+ @JvmStatic @Scheduled(cron = "0 0 2 * * *") fun static() {}
+ }
+ }
+
+ open class ExcludedJobs {
+ @Scheduled(cron = "0 0 2 * * *") open fun run() {}
+ }
+
+ class FinalJobs {
+ @Scheduled(cron = "0 0 2 * * *") fun run() {}
+ }
+
+ open class ParentJobs {
+ @Scheduled(cron = "0 0 2 * * *") open fun run() {}
+ }
+
+ open class OverridingJobs : ParentJobs() {
+ override fun run() {}
+ }
+}
diff --git a/sentry-test-support/build.gradle.kts b/sentry-test-support/build.gradle.kts
index 2539beccb5f..b332f035958 100644
--- a/sentry-test-support/build.gradle.kts
+++ b/sentry-test-support/build.gradle.kts
@@ -4,6 +4,8 @@ plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.errorprone)
alias(libs.plugins.gradle.versions)
+ id("io.sentry.spotless")
+ id("io.sentry.jdk-target")
}
tasks.withType().configureEach {
diff --git a/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt
index e799d7520e6..31944ec22cb 100644
--- a/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt
+++ b/sentry-test-support/src/main/kotlin/io/sentry/time/TestMonotonicTicker.kt
@@ -8,6 +8,9 @@ import java.util.concurrent.TimeUnit
* Advancing by an amount *and a unit* is the point: a stubbed `thenReturn(1001)` against a
* nanosecond ticker is off by a factor of a million and still compiles, whereas `advance(1001,
* MILLISECONDS)` cannot be.
+ *
+ * The tick is volatile so that a test thread can advance the ticker while the code under test reads
+ * it from another thread.
*/
class TestMonotonicTicker(@Volatile private var nanos: Long = 0) : MonotonicTicker {
override fun tickNanos(): Long = nanos
diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api
index f5dba14a31a..b3c312169a3 100644
--- a/sentry/api/sentry.api
+++ b/sentry/api/sentry.api
@@ -7816,6 +7816,12 @@ public final class io/sentry/util/MapObjectWriter : io/sentry/ObjectWriter {
public fun value (Z)Lio/sentry/util/MapObjectWriter;
}
+public final class io/sentry/util/MonitorConfigUtils {
+ public static fun fromSchedule (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;)Lio/sentry/MonitorConfig;
+ public static fun fromSpringScheduled (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Z)Lio/sentry/MonitorConfig;
+ public static fun parsePeriodMillis (Ljava/lang/String;Ljava/util/concurrent/TimeUnit;)Ljava/lang/Long;
+}
+
public final class io/sentry/util/Objects {
public static fun equals (Ljava/lang/Object;Ljava/lang/Object;)Z
public static fun hash ([Ljava/lang/Object;)I
@@ -7984,11 +7990,12 @@ public final class io/sentry/util/network/NetworkBody$NetworkBodyWarning : java/
public final class io/sentry/util/network/NetworkBodyParser {
public static fun fromBytes ([BLjava/lang/String;Ljava/lang/String;ILio/sentry/ILogger;)Lio/sentry/util/network/NetworkBody;
+ public static fun fromBytes ([BLjava/lang/String;Ljava/lang/String;IZLio/sentry/ILogger;)Lio/sentry/util/network/NetworkBody;
}
public final class io/sentry/util/network/NetworkDetailCaptureUtils {
public static fun createRequest (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;Ljava/util/List;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse;
- public static fun createResponse (Ljava/lang/Object;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;Ljava/util/List;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse;
+ public static fun createResponse (Ljava/lang/Object;Ljava/lang/Integer;Ljava/lang/Long;ZLio/sentry/util/network/NetworkDetailCaptureUtils$NetworkBodyExtractor;Ljava/util/List;Lio/sentry/util/network/NetworkDetailCaptureUtils$NetworkHeaderExtractor;)Lio/sentry/util/network/ReplayNetworkRequestOrResponse;
public static fun initializeForUrl (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/util/List;)Lio/sentry/util/network/NetworkRequestData;
}
@@ -8009,15 +8016,17 @@ public final class io/sentry/util/network/NetworkRequestData {
public fun getResponseBodySize ()Ljava/lang/Long;
public fun getStatusCode ()Ljava/lang/Integer;
public fun setRequestDetails (Lio/sentry/util/network/ReplayNetworkRequestOrResponse;)V
- public fun setResponseDetails (ILio/sentry/util/network/ReplayNetworkRequestOrResponse;)V
+ public fun setResponseDetails (Lio/sentry/util/network/ReplayNetworkRequestOrResponse;)V
public fun toString ()Ljava/lang/String;
}
public final class io/sentry/util/network/ReplayNetworkRequestOrResponse {
+ public fun (Ljava/lang/Integer;Ljava/lang/Long;Lio/sentry/util/network/NetworkBody;Ljava/util/Map;)V
public fun (Ljava/lang/Long;Lio/sentry/util/network/NetworkBody;Ljava/util/Map;)V
public fun getBody ()Lio/sentry/util/network/NetworkBody;
public fun getHeaders ()Ljava/util/Map;
public fun getSize ()Ljava/lang/Long;
+ public fun getStatusCode ()Ljava/lang/Integer;
public fun toString ()Ljava/lang/String;
}
diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts
index 5428fa1722f..6b07d9d746d 100644
--- a/sentry/build.gradle.kts
+++ b/sentry/build.gradle.kts
@@ -9,6 +9,9 @@ plugins {
alias(libs.plugins.gradle.versions)
alias(libs.plugins.buildconfig)
id("io.sentry.animalsniffer.android")
+ id("io.sentry.spotless")
+ id("io.sentry.jdk-target")
+ id("io.sentry.publish")
id("io.sentry.api-validation")
}
diff --git a/sentry/src/main/java/io/sentry/time/JavaMonotonicTicker.java b/sentry/src/main/java/io/sentry/time/JavaMonotonicTicker.java
index b5b000f2805..fab92fd1b9a 100644
--- a/sentry/src/main/java/io/sentry/time/JavaMonotonicTicker.java
+++ b/sentry/src/main/java/io/sentry/time/JavaMonotonicTicker.java
@@ -9,6 +9,11 @@ public final class JavaMonotonicTicker implements MonotonicTicker {
private static final JavaMonotonicTicker instance = new JavaMonotonicTicker();
+ /**
+ * Prefer {@link io.sentry.SentryOptions#getMonotonicTicker()}, which picks the platform's ticker.
+ * On Android this one stops counting during device suspend, so intervals spanning a deep sleep
+ * come out short.
+ */
public static @NotNull MonotonicTicker getInstance() {
return instance;
}
diff --git a/sentry/src/main/java/io/sentry/time/SystemEpochClock.java b/sentry/src/main/java/io/sentry/time/SystemEpochClock.java
index 2cb037c0e09..b11eb18fb72 100644
--- a/sentry/src/main/java/io/sentry/time/SystemEpochClock.java
+++ b/sentry/src/main/java/io/sentry/time/SystemEpochClock.java
@@ -24,6 +24,10 @@ public final class SystemEpochClock implements EpochClock {
private static final SystemEpochClock instance = new SystemEpochClock();
+ /**
+ * Prefer {@link io.sentry.SentryOptions#getEpochClock()}, which is where a platform would
+ * substitute its own wall clock, the way Android already substitutes the ticker.
+ */
public static @NotNull EpochClock getInstance() {
return instance;
}
diff --git a/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java
new file mode 100644
index 00000000000..4058529b12f
--- /dev/null
+++ b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java
@@ -0,0 +1,507 @@
+package io.sentry.util;
+
+import io.sentry.MonitorConfig;
+import io.sentry.MonitorSchedule;
+import io.sentry.MonitorScheduleUnit;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+/** Converts scheduler definitions into a {@link MonitorConfig}. */
+@ApiStatus.Internal
+public final class MonitorConfigUtils {
+ private static final @NotNull String CRON_DISABLED = "-";
+ private static final long MINUTE_MILLIS = TimeUnit.MINUTES.toMillis(1);
+ private static final long MIN_CHECKIN_MARGIN_WITH_SECONDS = 2;
+ private static final @NotNull Map CRON_MACROS;
+ private static final @NotNull Pattern SIMPLE_DURATION =
+ Pattern.compile("^([+-]?\\d+)([a-zA-Z]{0,2})$");
+ private static final @NotNull Pattern FIXED_OFFSET =
+ Pattern.compile("^(?:GMT|UTC)?([+-])(\\d{1,2})(?::?(\\d{2}))?$");
+ private static final @NotNull Pattern DIGITS = Pattern.compile("\\d+");
+
+ private static final int DAY_OF_MONTH = 2;
+ private static final int MONTH = 3;
+ private static final int DAY_OF_WEEK = 4;
+ private static final int[] FIELD_MIN = {0, 0, 1, 1, 0};
+ private static final int[] FIELD_MAX = {59, 23, 31, 12, 7};
+ private static final int LAST = -1;
+ private static final int[] DAYS_IN_MONTH = {31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
+ private static final @NotNull List MONTH_NAMES =
+ Arrays.asList(
+ "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC");
+ private static final @NotNull List DAY_NAMES =
+ Arrays.asList("SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT");
+
+ /** IANA zones without a slash; Java also knows non-IANA short IDs like PST. */
+ private static final @NotNull Set