From ea62aa186978c0b0c993f44a9bec5806acfcb518 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 12:53:47 -0700 Subject: [PATCH 01/38] ref(core): Add MonitorConfigUtils to convert schedules to monitor configs Framework-free conversion of a 6 field cron expression, fixed rate or fixed delay into a MonitorConfig, plus period parsing, so the Spring and Quartz integrations can share it. --- sentry/api/sentry.api | 5 + .../io/sentry/util/MonitorConfigUtils.java | 155 +++++++++++++++ .../io/sentry/util/MonitorConfigUtilsTest.kt | 185 ++++++++++++++++++ 3 files changed, 345 insertions(+) create mode 100644 sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java create mode 100644 sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 01b068ee689..bd41c383406 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -8120,6 +8120,11 @@ 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;Ljava/lang/Long;)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 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..584227c4e7e --- /dev/null +++ b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java @@ -0,0 +1,155 @@ +package io.sentry.util; + +import io.sentry.MonitorConfig; +import io.sentry.MonitorSchedule; +import io.sentry.MonitorScheduleUnit; +import java.util.Collections; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +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 @NotNull Map CRON_MACROS; + private static final @NotNull Pattern SIMPLE_DURATION = + Pattern.compile("^([+-]?\\d+)([a-zA-Z]{0,2})$"); + + static { + final @NotNull Map macros = new HashMap<>(); + macros.put("@yearly", "0 0 1 1 *"); + macros.put("@annually", "0 0 1 1 *"); + macros.put("@monthly", "0 0 1 * *"); + macros.put("@weekly", "0 0 * * 0"); + macros.put("@daily", "0 0 * * *"); + macros.put("@midnight", "0 0 * * *"); + macros.put("@hourly", "0 * * * *"); + CRON_MACROS = Collections.unmodifiableMap(macros); + } + + private MonitorConfigUtils() {} + + /** + * Builds a monitor config from exactly one of a cron, fixed rate or fixed delay. + * + * @param cron 6 field cron (seconds first) or macro, null or empty if unset + * @param zone cron time zone, ignored for intervals + * @param fixedRateMillis null if unset + * @param fixedDelayMillis null if unset + * @return null if the schedule can't be expressed as a Sentry monitor schedule + */ + public static @Nullable MonitorConfig fromSchedule( + final @Nullable String cron, + final @Nullable String zone, + final @Nullable Long fixedRateMillis, + final @Nullable Long fixedDelayMillis) { + final boolean hasCron = cron != null && !cron.isEmpty(); + + if (hasCron && cron != null && fixedRateMillis == null && fixedDelayMillis == null) { + final @Nullable String crontab = toCrontab(cron); + if (crontab == null) { + return null; + } + final @NotNull MonitorConfig config = new MonitorConfig(MonitorSchedule.crontab(crontab)); + if (zone != null && !zone.isEmpty()) { + config.setTimezone(zone); + } + return config; + } + + final @Nullable Long period = fixedRateMillis != null ? fixedRateMillis : fixedDelayMillis; + if (!hasCron && period != null && (fixedRateMillis == null || fixedDelayMillis == null)) { + final long millis = period; + if (millis < MINUTE_MILLIS || millis % MINUTE_MILLIS != 0) { + return null; + } + final long minutes = millis / MINUTE_MILLIS; + if (minutes > Integer.MAX_VALUE) { + return null; + } + return new MonitorConfig(MonitorSchedule.interval((int) minutes, MonitorScheduleUnit.MINUTE)); + } + return null; + } + + /** + * Parses a plain number in {@code defaultUnit} or a Spring 6.1 simple duration like {@code 30s}. + * ISO-8601 is not supported. + * + * @return milliseconds, or null if unparseable + */ + public static @Nullable Long parsePeriodMillis( + final @Nullable String value, final @NotNull TimeUnit defaultUnit) { + if (value == null) { + return null; + } + final @NotNull Matcher matcher = SIMPLE_DURATION.matcher(value.trim()); + if (!matcher.matches()) { + return null; + } + final long amount; + try { + amount = Long.parseLong(matcher.group(1)); + } catch (NumberFormatException e) { + return null; + } + final @Nullable TimeUnit unit = timeUnit(matcher.group(2), defaultUnit); + return unit == null ? null : unit.toMillis(amount); + } + + private static @Nullable TimeUnit timeUnit( + final @NotNull String suffix, final @NotNull TimeUnit defaultUnit) { + switch (suffix.toLowerCase(Locale.ROOT)) { + case "": + return defaultUnit; + case "ns": + return TimeUnit.NANOSECONDS; + case "us": + return TimeUnit.MICROSECONDS; + case "ms": + return TimeUnit.MILLISECONDS; + case "s": + return TimeUnit.SECONDS; + case "m": + return TimeUnit.MINUTES; + case "h": + return TimeUnit.HOURS; + case "d": + return TimeUnit.DAYS; + default: + return null; + } + } + + /** Drops a fixed seconds field; returns null otherwise, as crontab has no seconds. */ + static @Nullable String toCrontab(final @NotNull String cron) { + final @NotNull String trimmed = cron.trim(); + if (CRON_DISABLED.equals(trimmed)) { + return null; + } + if (trimmed.startsWith("@")) { + return CRON_MACROS.get(trimmed.toLowerCase(Locale.ROOT)); + } + final @NotNull String[] fields = trimmed.split("\\s+", -1); + if (fields.length != 6 || !fields[0].matches("\\d{1,2}")) { + return null; + } + final @NotNull StringBuilder crontab = new StringBuilder(); + for (int i = 1; i < fields.length; i++) { + if (i > 1) { + crontab.append(' '); + } + // '?' means '*' in Spring and Quartz cron + crontab.append("?".equals(fields[i]) ? "*" : fields[i]); + } + return crontab.toString(); + } +} diff --git a/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt new file mode 100644 index 00000000000..a6c471763fa --- /dev/null +++ b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt @@ -0,0 +1,185 @@ +package io.sentry.util + +import com.google.common.truth.Truth.assertThat +import java.util.concurrent.TimeUnit +import kotlin.test.Test + +class MonitorConfigUtilsTest { + + @Test + fun `cron with fixed seconds drops the seconds field`() { + val config = MonitorConfigUtils.fromSchedule("0 15 10 * * MON-FRI", null, null, null) + + assertThat(config).isNotNull() + assertThat(config!!.schedule.type).isEqualTo("crontab") + assertThat(config.schedule.value).isEqualTo("15 10 * * MON-FRI") + assertThat(config.schedule.unit).isNull() + assertThat(config.timezone).isNull() + } + + @Test + fun `cron whitespace is normalized`() { + val config = MonitorConfigUtils.fromSchedule(" 30 0 2 * * * ", null, null, null) + + assertThat(config?.schedule?.value).isEqualTo("0 2 * * *") + } + + @Test + fun `cron question mark is converted to asterisk`() { + val config = MonitorConfigUtils.fromSchedule("0 0 9 ? * MON", null, null, null) + + assertThat(config?.schedule?.value).isEqualTo("0 9 * * MON") + } + + @Test + fun `cron macros are expanded`() { + val expected = + mapOf( + "@yearly" to "0 0 1 1 *", + "@annually" to "0 0 1 1 *", + "@monthly" to "0 0 1 * *", + "@weekly" to "0 0 * * 0", + "@daily" to "0 0 * * *", + "@midnight" to "0 0 * * *", + "@hourly" to "0 * * * *", + "@HOURLY" to "0 * * * *", + ) + for ((macro, crontab) in expected) { + assertThat(MonitorConfigUtils.fromSchedule(macro, null, null, null)?.schedule?.value) + .isEqualTo(crontab) + } + } + + @Test + fun `unknown cron macro returns null`() { + assertThat(MonitorConfigUtils.fromSchedule("@reboot", null, null, null)).isNull() + } + + @Test + fun `cron with variable seconds returns null`() { + assertThat(MonitorConfigUtils.fromSchedule("*/30 * * * * *", null, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0,30 * * * * *", null, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0-10 * * * * *", null, null, null)).isNull() + } + + @Test + fun `cron without 6 fields returns null`() { + assertThat(MonitorConfigUtils.fromSchedule("0 * * * *", null, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0 0 * * * * 2030", null, null, null)).isNull() + } + + @Test + fun `disabled cron returns null`() { + assertThat(MonitorConfigUtils.fromSchedule("-", null, null, null)).isNull() + } + + @Test + fun `zone is set for cron`() { + val config = MonitorConfigUtils.fromSchedule("0 0 2 * * *", "Europe/Vienna", null, null) + + assertThat(config?.schedule?.value).isEqualTo("0 2 * * *") + assertThat(config?.timezone).isEqualTo("Europe/Vienna") + } + + @Test + fun `empty zone is not set`() { + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", "", null, null)?.timezone).isNull() + } + + @Test + fun `fixed rate in whole minutes is an interval`() { + val config = MonitorConfigUtils.fromSchedule(null, null, TimeUnit.MINUTES.toMillis(5), null) + + assertThat(config).isNotNull() + assertThat(config!!.schedule.type).isEqualTo("interval") + assertThat(config.schedule.value).isEqualTo("5") + assertThat(config.schedule.unit).isEqualTo("minute") + } + + @Test + fun `fixed delay in whole minutes is an interval`() { + val config = MonitorConfigUtils.fromSchedule("", null, null, TimeUnit.HOURS.toMillis(2)) + + assertThat(config?.schedule?.value).isEqualTo("120") + assertThat(config?.schedule?.unit).isEqualTo("minute") + } + + @Test + fun `zone is ignored for intervals`() { + val config = + MonitorConfigUtils.fromSchedule(null, "Europe/Vienna", TimeUnit.MINUTES.toMillis(1), null) + + assertThat(config?.schedule?.value).isEqualTo("1") + assertThat(config?.timezone).isNull() + } + + @Test + fun `sub-minute period returns null`() { + assertThat(MonitorConfigUtils.fromSchedule(null, null, 30_000L, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule(null, null, 0L, null)).isNull() + } + + @Test + fun `period not in whole minutes returns null`() { + assertThat(MonitorConfigUtils.fromSchedule(null, null, 90_000L, null)).isNull() + } + + @Test + fun `period too large for an int returns null`() { + assertThat( + MonitorConfigUtils.fromSchedule( + null, + null, + TimeUnit.MINUTES.toMillis(Int.MAX_VALUE.toLong() + 1), + null, + ) + ) + .isNull() + } + + @Test + fun `more than one schedule kind returns null`() { + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", null, 60_000L, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule(null, null, 60_000L, 60_000L)).isNull() + } + + @Test + fun `no schedule returns null`() { + assertThat(MonitorConfigUtils.fromSchedule(null, null, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("", null, null, null)).isNull() + } + + @Test + fun `parsePeriodMillis parses plain numbers in the default unit`() { + assertThat(MonitorConfigUtils.parsePeriodMillis("300000", TimeUnit.MILLISECONDS)) + .isEqualTo(300_000L) + assertThat(MonitorConfigUtils.parsePeriodMillis(" 5 ", TimeUnit.MINUTES)).isEqualTo(300_000L) + } + + @Test + fun `parsePeriodMillis parses the simple duration style`() { + val expected = + mapOf( + "2000000ns" to 2L, + "3000us" to 3L, + "500ms" to 500L, + "30s" to 30_000L, + "5m" to 300_000L, + "1h" to 3_600_000L, + "1d" to 86_400_000L, + "5M" to 300_000L, + "+5m" to 300_000L, + ) + for ((value, millis) in expected) { + assertThat(MonitorConfigUtils.parsePeriodMillis(value, TimeUnit.SECONDS)).isEqualTo(millis) + } + } + + @Test + fun `parsePeriodMillis returns null for unparseable values`() { + for (value in listOf("", "abc", "5x", "5 m", "1.5m", "PT5M", "99999999999999999999")) { + assertThat(MonitorConfigUtils.parsePeriodMillis(value, TimeUnit.MILLISECONDS)).isNull() + } + assertThat(MonitorConfigUtils.parsePeriodMillis(null, TimeUnit.MILLISECONDS)).isNull() + } +} From 28761ae677b0c8fdb43bfab4e3404d905168e402 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 13:30:26 -0700 Subject: [PATCH 02/38] fix(core): Only send monitor configs Sentry reads the same way Return no config when day of month and day of week are both set, for cron syntax Sentry rejects, and for zones that are not IANA region IDs. Whole-hour fixed offsets map to Etc/GMT zones. Intervals use hours or days when they divide evenly. --- .../io/sentry/util/MonitorConfigUtils.java | 250 +++++++++++++++++- .../io/sentry/util/MonitorConfigUtilsTest.kt | 137 +++++++++- 2 files changed, 372 insertions(+), 15 deletions(-) diff --git a/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java index 584227c4e7e..a96def263a0 100644 --- a/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java +++ b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java @@ -3,10 +3,16 @@ 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; @@ -22,6 +28,34 @@ public final class MonitorConfigUtils { 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 TOP_LEVEL_ZONES = + new HashSet<>( + Arrays.asList( + ("CET CST6CDT Cuba EET EST EST5EDT Egypt Eire GB GB-Eire GMT GMT+0 GMT-0 GMT0 " + + "Greenwich HST Hongkong Iceland Iran Israel Jamaica Japan Kwajalein Libya " + + "MET MST MST7MDT NZ NZ-CHAT Navajo PRC PST8PDT Poland Portugal ROC ROK " + + "Singapore Turkey UCT UTC Universal W-SU WET Zulu") + .split(" "))); + + private static volatile @Nullable Set availableZoneIds; static { final @NotNull Map macros = new HashMap<>(); @@ -60,7 +94,11 @@ private MonitorConfigUtils() {} } final @NotNull MonitorConfig config = new MonitorConfig(MonitorSchedule.crontab(crontab)); if (zone != null && !zone.isEmpty()) { - config.setTimezone(zone); + final @Nullable String timezone = toTimezone(zone); + if (timezone == null) { + return null; + } + config.setTimezone(timezone); } return config; } @@ -71,11 +109,19 @@ private MonitorConfigUtils() {} if (millis < MINUTE_MILLIS || millis % MINUTE_MILLIS != 0) { return null; } - final long minutes = millis / MINUTE_MILLIS; - if (minutes > Integer.MAX_VALUE) { + long value = millis / MINUTE_MILLIS; + @NotNull MonitorScheduleUnit unit = MonitorScheduleUnit.MINUTE; + if (value % (24 * 60) == 0) { + value /= 24 * 60; + unit = MonitorScheduleUnit.DAY; + } else if (value % 60 == 0) { + value /= 60; + unit = MonitorScheduleUnit.HOUR; + } + if (value > Integer.MAX_VALUE) { return null; } - return new MonitorConfig(MonitorSchedule.interval((int) minutes, MonitorScheduleUnit.MINUTE)); + return new MonitorConfig(MonitorSchedule.interval((int) value, unit)); } return null; } @@ -129,7 +175,50 @@ private MonitorConfigUtils() {} } } - /** Drops a fixed seconds field; returns null otherwise, as crontab has no seconds. */ + /** + * Returns a zone ID Sentry accepts: IANA region IDs as is, whole-hour fixed offsets like {@code + * GMT+2} as {@code Etc/GMT-2}, null otherwise. + */ + static @Nullable String toTimezone(final @NotNull String zone) { + final @NotNull Matcher offset = FIXED_OFFSET.matcher(zone); + if (offset.matches()) { + final @Nullable String minutes = offset.group(3); + if (minutes != null && !"00".equals(minutes)) { + return null; + } + final int hours = Integer.parseInt(offset.group(2)); + if (hours == 0) { + return "Etc/GMT"; + } + final boolean east = "+".equals(offset.group(1)); + if (hours > (east ? 14 : 12)) { + return null; + } + // Etc/GMT signs are inverted + return "Etc/GMT" + (east ? "-" : "+") + hours; + } + if (TOP_LEVEL_ZONES.contains(zone)) { + return zone; + } + if (zone.indexOf('/') > 0 && !zone.startsWith("SystemV/") && availableZones().contains(zone)) { + return zone; + } + return null; + } + + private static @NotNull Set availableZones() { + @Nullable Set zones = availableZoneIds; + if (zones == null) { + zones = new HashSet<>(Arrays.asList(TimeZone.getAvailableIDs())); + availableZoneIds = zones; + } + return zones; + } + + /** + * Drops a fixed seconds field. Returns null if Sentry would reject the result or read it + * differently, as crontab has no seconds and ORs day of month and day of week. + */ static @Nullable String toCrontab(final @NotNull String cron) { final @NotNull String trimmed = cron.trim(); if (CRON_DISABLED.equals(trimmed)) { @@ -139,17 +228,152 @@ private MonitorConfigUtils() {} return CRON_MACROS.get(trimmed.toLowerCase(Locale.ROOT)); } final @NotNull String[] fields = trimmed.split("\\s+", -1); - if (fields.length != 6 || !fields[0].matches("\\d{1,2}")) { + if (fields.length != 6 || !fields[0].matches("\\d{1,2}") || Integer.parseInt(fields[0]) > 59) { return null; } - final @NotNull StringBuilder crontab = new StringBuilder(); - for (int i = 1; i < fields.length; i++) { - if (i > 1) { - crontab.append(' '); - } + final @NotNull String[] crontab = new String[5]; + for (int i = 0; i < crontab.length; i++) { + final @NotNull String field = fields[i + 1]; // '?' means '*' in Spring and Quartz cron - crontab.append("?".equals(fields[i]) ? "*" : fields[i]); + final boolean dayField = i == DAY_OF_MONTH || i == DAY_OF_WEEK; + crontab[i] = dayField && "?".equals(field) ? "*" : field; + } + // crontab ORs day of month and day of week unless one starts with '*', Spring and Quartz AND + if (!crontab[DAY_OF_MONTH].startsWith("*") && !crontab[DAY_OF_WEEK].startsWith("*")) { + return null; + } + final @NotNull List> values = new ArrayList<>(); + for (int i = 0; i < crontab.length; i++) { + final @Nullable Set parsed = parseField(crontab[i].toUpperCase(Locale.ROOT), i); + if (parsed == null) { + return null; + } + values.add(parsed); + } + if (!dayExistsInMonths(values.get(DAY_OF_MONTH), values.get(MONTH))) { + return null; + } + final @NotNull StringBuilder result = new StringBuilder(); + for (int i = 0; i < crontab.length; i++) { + if (i > 0) { + result.append(' '); + } + result.append(crontab[i]); + } + return result.toString(); + } + + // Mirrors the parser Sentry uses (cronsim). Returns null for anything it rejects or that we + // can't send as is. Day of week L and # entries are not expanded, as only the count matters. + private static @Nullable Set parseField(final @NotNull String field, final int index) { + final @NotNull Set result = new HashSet<>(); + for (final @NotNull String term : field.split(",", -1)) { + final @Nullable Set parsed = parseTerm(term, index); + if (parsed == null) { + return null; + } + result.addAll(parsed); + } + return result; + } + + private static @Nullable Set parseTerm(final @NotNull String term, final int index) { + if ("*".equals(term)) { + return range(FIELD_MIN[index], FIELD_MAX[index], 1); + } + if (index == DAY_OF_WEEK && term.indexOf('L') >= 0) { + final @NotNull String day = term.substring(0, term.length() - 1); + if (!term.endsWith("L") || !DIGITS.matcher(day).matches() || value(day, index) == null) { + return null; + } + return Collections.singleton(LAST); + } + if (index == DAY_OF_WEEK && term.indexOf('#') >= 0) { + final int hash = term.indexOf('#'); + final @Nullable Integer nth = number(term.substring(hash + 1)); + if (nth == null || nth < 1 || nth > 5 || value(term.substring(0, hash), index) == null) { + return null; + } + return Collections.singleton(LAST); + } + if (term.indexOf('/') >= 0) { + final int slash = term.indexOf('/'); + final @Nullable Integer step = number(term.substring(slash + 1)); + final @NotNull String base = term.substring(0, slash); + if (step == null || step == 0 || "L".equals(base) || "LW".equals(base)) { + return null; + } + final @Nullable Set items = parseTerm(base, index); + if (items == null) { + return null; + } + if (items.size() == 1) { + return range(items.iterator().next(), FIELD_MAX[index], step); + } + final @NotNull List sorted = new ArrayList<>(items); + Collections.sort(sorted); + final @NotNull Set result = new HashSet<>(); + for (int i = 0; i < sorted.size(); i += step) { + result.add(sorted.get(i)); + } + return result; + } + if (term.indexOf('-') >= 0) { + final int dash = term.indexOf('-'); + final @Nullable Integer start = value(term.substring(0, dash), index); + final @Nullable Integer end = value(term.substring(dash + 1), index); + if (start == null || end == null || end < start) { + return null; + } + return range(start, end, 1); + } + if (index == DAY_OF_MONTH && ("L".equals(term) || "LW".equals(term))) { + return Collections.singleton(LAST); + } + final @Nullable Integer value = value(term, index); + return value == null ? null : Collections.singleton(value); + } + + private static @Nullable Integer value(final @NotNull String value, final int index) { + if (index == MONTH && MONTH_NAMES.contains(value)) { + return MONTH_NAMES.indexOf(value) + 1; + } + if (index == DAY_OF_WEEK && DAY_NAMES.contains(value)) { + return DAY_NAMES.indexOf(value); + } + final @Nullable Integer number = number(value); + if (number == null || number < FIELD_MIN[index] || number > FIELD_MAX[index]) { + return null; + } + return number; + } + + private static @Nullable Integer number(final @NotNull String value) { + if (value.isEmpty() || value.length() > 9 || !DIGITS.matcher(value).matches()) { + return null; + } + return Integer.parseInt(value); + } + + private static @NotNull Set range(final int start, final int end, final int step) { + final @NotNull Set result = new HashSet<>(); + for (int i = start; i <= end; i += step) { + result.add(i); + } + return result; + } + + private static boolean dayExistsInMonths( + final @NotNull Set days, final @NotNull Set months) { + final int firstDay = Collections.min(days); + if (firstDay <= 29) { + return true; + } + for (final int month : months) { + if (firstDay <= DAYS_IN_MONTH[month - 1]) { + return true; + } } - return crontab.toString(); + return false; } } diff --git a/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt index a6c471763fa..cee9d2a7141 100644 --- a/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt @@ -68,6 +68,88 @@ class MonitorConfigUtilsTest { assertThat(MonitorConfigUtils.fromSchedule("0 0 * * * * 2030", null, null, null)).isNull() } + @Test + fun `cron with both day of month and day of week returns null`() { + for (cron in listOf("0 0 9 1-7 * MON", "0 0 9 15 * 1-5", "0 0 9 L * 5L", "0 0 9 1 * 1/2")) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null, null)).isNull() + } + } + + @Test + fun `cron with a day field starting with asterisk is kept`() { + val expected = + mapOf( + "0 0 9 */2 * MON" to "0 9 */2 * MON", + "0 0 9 15 * */2" to "0 9 15 * */2", + "0 0 9 1 * ?" to "0 9 1 * *", + ) + for ((cron, crontab) in expected) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null, null)?.schedule?.value) + .isEqualTo(crontab) + } + } + + @Test + fun `cron syntax Sentry accepts is kept`() { + val expected = + listOf( + "0 */15 * * * *", + "0 5/15 * * * *", + "0 0,30 8-18/2 * * *", + "0 0 0 1,15 JAN-MAR,dec *", + "0 0 0 L * *", + "0 0 0 lw * *", + "0 0 0 31 1-12 *", + "0 0 0 * * 0,7", + "0 0 0 * * sat", + "0 0 0 * * MON-FRI/2", + "0 0 0 * * 5L", + "0 0 0 * * 5#3", + "0 0 0 * * MON#2", + "0 0 0 * * 1-7/2", + ) + for (cron in expected) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null, null)?.schedule?.value) + .isEqualTo(cron.substringAfter(' ')) + } + } + + @Test + fun `cron syntax Sentry rejects returns null`() { + val rejected = + listOf( + "60 * * * * *", + "0 60 * * * *", + "0 0 24 * * *", + "0 0 22-2 * * *", + "0 */0 * * * *", + "0 0,,30 * * * *", + "0 ? * * * *", + "0 0 0 0 * *", + "0 0 0 32 * *", + "0 0 0 15W * *", + "0 0 0 LW/2 * *", + "0 0 0 L-3 * *", + "0 0 0 5L * *", + "0 0 0 1#2 * *", + "0 0 0 30 2 *", + "0 0 0 31 4,6 *", + "0 0 0 * 0 *", + "0 0 0 * 13 *", + "0 0 0 * FOO *", + "0 0 0 * * 8", + "0 0 0 * * SAT-SUN", + "0 0 0 * * L", + "0 0 0 * * FRIL", + "0 0 0 * * L5", + "0 0 0 * * 5#6", + "0 0 0 * * 5#0", + ) + for (cron in rejected) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null, null)).isNull() + } + } + @Test fun `disabled cron returns null`() { assertThat(MonitorConfigUtils.fromSchedule("-", null, null, null)).isNull() @@ -81,6 +163,41 @@ class MonitorConfigUtilsTest { assertThat(config?.timezone).isEqualTo("Europe/Vienna") } + @Test + fun `IANA zones are kept`() { + for (zone in + listOf("UTC", "GMT", "Etc/GMT+5", "US/Eastern", "America/Argentina/Buenos_Aires")) { + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", zone, null, null)?.timezone) + .isEqualTo(zone) + } + } + + @Test + fun `whole hour fixed offsets are converted to Etc zones`() { + val expected = + mapOf( + "GMT+2" to "Etc/GMT-2", + "GMT+02:00" to "Etc/GMT-2", + "UTC-5" to "Etc/GMT+5", + "+03:00" to "Etc/GMT-3", + "-1200" to "Etc/GMT+12", + "GMT+14" to "Etc/GMT-14", + "GMT-00:00" to "Etc/GMT", + ) + for ((zone, timezone) in expected) { + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", zone, null, null)?.timezone) + .isEqualTo(timezone) + } + } + + @Test + fun `zones Sentry rejects return null`() { + for (zone in + listOf("GMT+05:30", "GMT+15", "UTC-13", "PST", "IST", "Nowhere/Land", "SystemV/EST5")) { + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", zone, null, null)).isNull() + } + } + @Test fun `empty zone is not set`() { assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", "", null, null)?.timezone).isNull() @@ -98,12 +215,28 @@ class MonitorConfigUtilsTest { @Test fun `fixed delay in whole minutes is an interval`() { - val config = MonitorConfigUtils.fromSchedule("", null, null, TimeUnit.HOURS.toMillis(2)) + val config = MonitorConfigUtils.fromSchedule("", null, null, TimeUnit.MINUTES.toMillis(90)) - assertThat(config?.schedule?.value).isEqualTo("120") + assertThat(config?.schedule?.value).isEqualTo("90") assertThat(config?.schedule?.unit).isEqualTo("minute") } + @Test + fun `period in whole hours or days uses the largest unit`() { + val expected = + mapOf( + TimeUnit.MINUTES.toMillis(60) to ("1" to "hour"), + TimeUnit.HOURS.toMillis(36) to ("36" to "hour"), + TimeUnit.HOURS.toMillis(24) to ("1" to "day"), + TimeUnit.DAYS.toMillis(14) to ("14" to "day"), + ) + for ((millis, interval) in expected) { + val config = MonitorConfigUtils.fromSchedule(null, null, millis, null) + assertThat(config?.schedule?.value).isEqualTo(interval.first) + assertThat(config?.schedule?.unit).isEqualTo(interval.second) + } + } + @Test fun `zone is ignored for intervals`() { val config = From bd470268326fee4d8b6cc7594b5217160887e84d Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 15:38:46 -0700 Subject: [PATCH 03/38] ref(core): Add MonitorConfigUtils.fromSpringScheduled Move the Spring day-of-week rewrite and the JVM default zone rule into core so the three Spring modules share them. --- sentry/api/sentry.api | 1 + .../io/sentry/util/MonitorConfigUtils.java | 62 +++++++++++++++++ .../io/sentry/util/MonitorConfigUtilsTest.kt | 66 +++++++++++++++++++ 3 files changed, 129 insertions(+) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index bd41c383406..57510ebf0a3 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -8122,6 +8122,7 @@ public final class io/sentry/util/MapObjectWriter : io/sentry/ObjectWriter { public final class io/sentry/util/MonitorConfigUtils { public static fun fromSchedule (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;)Lio/sentry/MonitorConfig; + public static fun fromSpringScheduled (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;)Lio/sentry/MonitorConfig; public static fun parsePeriodMillis (Ljava/lang/String;Ljava/util/concurrent/TimeUnit;)Ljava/lang/Long; } diff --git a/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java index a96def263a0..b1ddedd3d4f 100644 --- a/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java +++ b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java @@ -126,6 +126,68 @@ private MonitorConfigUtils() {} return null; } + /** + * Builds a monitor config from Spring {@code @Scheduled} values with placeholders resolved. Reads + * days of week and a cron without zone the way Spring does. + * + * @param cron Spring cron, null or empty if unset + * @param zone cron time zone, null or empty for the JVM default zone + * @param fixedRateMillis null if unset + * @param fixedDelayMillis null if unset + * @return null if the schedule can't be expressed as a Sentry monitor schedule + */ + public static @Nullable MonitorConfig fromSpringScheduled( + final @Nullable String cron, + final @Nullable String zone, + final @Nullable Long fixedRateMillis, + final @Nullable Long fixedDelayMillis) { + final @Nullable String crontabCron = toCrontabDaysOfWeek(cron); + @Nullable String cronZone = null; + if (crontabCron != null && !crontabCron.isEmpty()) { + cronZone = zone == null || zone.isEmpty() ? TimeZone.getDefault().getID() : zone; + } + return fromSchedule(crontabCron, cronZone, fixedRateMillis, fixedDelayMillis); + } + + /** + * Spring numbers days of week from Monday with 0 or 7 for Sunday, and starts {@code *} on Monday. + * Rewrites them so crontab reads them the same way. + */ + static @Nullable String toCrontabDaysOfWeek(final @Nullable String cron) { + if (cron == null) { + return null; + } + final @NotNull String[] fields = cron.trim().split("\\s+", -1); + if (fields.length != 6) { + return cron; + } + final @NotNull StringBuilder daysOfWeek = new StringBuilder(); + for (@NotNull String item : fields[5].toUpperCase(Locale.ROOT).split(",", -1)) { + for (int i = 0; i < DAY_NAMES.size(); i++) { + item = item.replace(DAY_NAMES.get(i), String.valueOf(i == 0 ? 7 : i)); + } + if (item.startsWith("*/")) { + item = "1-7" + item.substring(1); + } else if (item.startsWith("7-")) { + // Sunday starting a range is 0 in Spring + item = "0" + item.substring(1); + } + if (daysOfWeek.length() > 0) { + daysOfWeek.append(','); + } + daysOfWeek.append(item); + } + fields[5] = daysOfWeek.toString(); + final @NotNull StringBuilder result = new StringBuilder(); + for (int i = 0; i < fields.length; i++) { + if (i > 0) { + result.append(' '); + } + result.append(fields[i]); + } + return result.toString(); + } + /** * Parses a plain number in {@code defaultUnit} or a Spring 6.1 simple duration like {@code 30s}. * ISO-8601 is not supported. diff --git a/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt index cee9d2a7141..fd476ceb4ea 100644 --- a/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt @@ -1,6 +1,7 @@ package io.sentry.util import com.google.common.truth.Truth.assertThat +import java.util.TimeZone import java.util.concurrent.TimeUnit import kotlin.test.Test @@ -282,6 +283,71 @@ class MonitorConfigUtilsTest { assertThat(MonitorConfigUtils.fromSchedule("", null, null, null)).isNull() } + @Test + fun `Spring days of week are rewritten to how crontab reads them`() { + val expected = + mapOf( + "0 15 10 * * MON-FRI" to "15 10 * * 1-5", + "0 0 9 * * */2" to "0 9 * * 1-7/2", + "0 0 9 ? * SUN/2" to "0 9 * * 7/2", + "0 0 9 ? * SUN-TUE,fri-sun" to "0 9 * * 0-2,5-7", + "0 0 9 ? * 7-2" to "0 9 * * 0-2", + "0 0 9 ? * 0,7" to "0 9 * * 0,7", + "0 0 9 ? * FRIL" to "0 9 * * 5L", + "0 0 9 ? * MON#2" to "0 9 * * 1#2", + "0 0 9 * * *" to "0 9 * * *", + ) + for ((cron, crontab) in expected) { + assertThat(MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null)?.schedule?.value) + .isEqualTo(crontab) + } + } + + @Test + fun `Spring cron without zone uses the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + assertThat(MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", null, null, null)?.timezone) + .isEqualTo("Asia/Tokyo") + assertThat(MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", "", null, null)?.timezone) + .isEqualTo("Asia/Tokyo") + } finally { + TimeZone.setDefault(defaultTimeZone) + } + } + + @Test + fun `Spring cron zone is converted`() { + val config = MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", "GMT+2", null, null) + + assertThat(config?.schedule?.value).isEqualTo("0 2 * * *") + assertThat(config?.timezone).isEqualTo("Etc/GMT-2") + } + + @Test + fun `Spring schedules Sentry can't express return null`() { + for (cron in listOf("0 0 9 1-7 * MON", "-", "\${my.cron.missing}", "*/30 * * * * *")) { + assertThat(MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null)).isNull() + } + assertThat(MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", "GMT+05:30", null, null)) + .isNull() + assertThat(MonitorConfigUtils.fromSpringScheduled(null, null, 30_000L, null)).isNull() + } + + @Test + fun `Spring periods are intervals without zone`() { + val rate = MonitorConfigUtils.fromSpringScheduled("", null, TimeUnit.MINUTES.toMillis(5), null) + assertThat(rate?.schedule?.type).isEqualTo("interval") + assertThat(rate?.schedule?.value).isEqualTo("5") + assertThat(rate?.schedule?.unit).isEqualTo("minute") + assertThat(rate?.timezone).isNull() + + val delay = MonitorConfigUtils.fromSpringScheduled(null, null, null, TimeUnit.HOURS.toMillis(2)) + assertThat(delay?.schedule?.value).isEqualTo("2") + assertThat(delay?.schedule?.unit).isEqualTo("hour") + } + @Test fun `parsePeriodMillis parses plain numbers in the default unit`() { assertThat(MonitorConfigUtils.parsePeriodMillis("300000", TimeUnit.MILLISECONDS)) From fca421637cd3ac3514606c56bb9cd5ee10db7131 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 16:29:03 -0700 Subject: [PATCH 04/38] fix(core): Return no config for stepped single value ranges Spring and Quartz run 10-10/2 only at 10, cronsim steps to the field max. --- .../src/main/java/io/sentry/util/MonitorConfigUtils.java | 4 ++++ .../test/java/io/sentry/util/MonitorConfigUtilsTest.kt | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java index b1ddedd3d4f..8d4bd832d49 100644 --- a/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java +++ b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java @@ -370,6 +370,10 @@ private MonitorConfigUtils() {} return null; } if (items.size() == 1) { + // cronsim steps a single value range like 10-10/2 to the field max + if (base.indexOf('-') >= 0) { + return null; + } return range(items.iterator().next(), FIELD_MAX[index], step); } final @NotNull List sorted = new ArrayList<>(items); diff --git a/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt index fd476ceb4ea..d6976f7d148 100644 --- a/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt @@ -151,6 +151,15 @@ class MonitorConfigUtilsTest { } } + @Test + fun `stepped single value range returns null`() { + // Spring and Quartz run only at the value, cronsim steps to the field max + for (cron in + listOf("0 10-10/2 * * * *", "0 0 0-0/3 * * *", "0 0 0 ? * 4-4/3", "0 0 0 * 6-6/1 *")) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null, null)).isNull() + } + } + @Test fun `disabled cron returns null`() { assertThat(MonitorConfigUtils.fromSchedule("-", null, null, null)).isNull() From 780c60501bc140b5164f094243f91f58870b3ce4 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 16:47:19 -0700 Subject: [PATCH 05/38] fix(core): Read Spring crons per parser and skip fixed delays Spring before 5.3 steps day of month */n from 0 and can run at midnight when both day fields are set; read its crons that way. Spring 5.3 runs #5 in months without a fifth weekday, so return null for it. A fixed delay drifts from any interval, so it gets no config. --- sentry/api/sentry.api | 4 +- .../io/sentry/util/MonitorConfigUtils.java | 76 +++++++-- .../io/sentry/util/MonitorConfigUtilsTest.kt | 154 ++++++++++++------ 3 files changed, 166 insertions(+), 68 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 57510ebf0a3..a6f573385f7 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -8121,8 +8121,8 @@ public final class io/sentry/util/MapObjectWriter : io/sentry/ObjectWriter { } public final class io/sentry/util/MonitorConfigUtils { - public static fun fromSchedule (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;)Lio/sentry/MonitorConfig; - public static fun fromSpringScheduled (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;)Lio/sentry/MonitorConfig; + 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; } diff --git a/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java index 8d4bd832d49..fd4b2675690 100644 --- a/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java +++ b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java @@ -72,22 +72,20 @@ public final class MonitorConfigUtils { private MonitorConfigUtils() {} /** - * Builds a monitor config from exactly one of a cron, fixed rate or fixed delay. + * Builds a monitor config from exactly one of a cron or a fixed interval. * * @param cron 6 field cron (seconds first) or macro, null or empty if unset * @param zone cron time zone, ignored for intervals - * @param fixedRateMillis null if unset - * @param fixedDelayMillis null if unset + * @param intervalMillis fixed rate, null if unset * @return null if the schedule can't be expressed as a Sentry monitor schedule */ public static @Nullable MonitorConfig fromSchedule( final @Nullable String cron, final @Nullable String zone, - final @Nullable Long fixedRateMillis, - final @Nullable Long fixedDelayMillis) { + final @Nullable Long intervalMillis) { final boolean hasCron = cron != null && !cron.isEmpty(); - if (hasCron && cron != null && fixedRateMillis == null && fixedDelayMillis == null) { + if (hasCron && cron != null && intervalMillis == null) { final @Nullable String crontab = toCrontab(cron); if (crontab == null) { return null; @@ -103,9 +101,8 @@ private MonitorConfigUtils() {} return config; } - final @Nullable Long period = fixedRateMillis != null ? fixedRateMillis : fixedDelayMillis; - if (!hasCron && period != null && (fixedRateMillis == null || fixedDelayMillis == null)) { - final long millis = period; + if (!hasCron && intervalMillis != null) { + final long millis = intervalMillis; if (millis < MINUTE_MILLIS || millis % MINUTE_MILLIS != 0) { return null; } @@ -128,30 +125,72 @@ private MonitorConfigUtils() {} /** * Builds a monitor config from Spring {@code @Scheduled} values with placeholders resolved. Reads - * days of week and a cron without zone the way Spring does. + * the cron and a cron without zone the way Spring does. * * @param cron Spring cron, null or empty if unset * @param zone cron time zone, null or empty for the JVM default zone * @param fixedRateMillis null if unset - * @param fixedDelayMillis null if unset + * @param fixedDelayMillis null if unset; a fixed delay never gets a config + * @param legacyCronParser true for Spring before 5.3, which parses crons with {@code + * CronSequenceGenerator} * @return null if the schedule can't be expressed as a Sentry monitor schedule */ public static @Nullable MonitorConfig fromSpringScheduled( final @Nullable String cron, final @Nullable String zone, final @Nullable Long fixedRateMillis, - final @Nullable Long fixedDelayMillis) { - final @Nullable String crontabCron = toCrontabDaysOfWeek(cron); + final @Nullable Long fixedDelayMillis, + final boolean legacyCronParser) { + // runs start a delay after the previous run ends, so they drift from any interval + if (fixedDelayMillis != null) { + return null; + } + final @Nullable String crontabCron = + legacyCronParser ? toCrontabLegacy(cron) : toCrontabDaysOfWeek(cron); @Nullable String cronZone = null; if (crontabCron != null && !crontabCron.isEmpty()) { cronZone = zone == null || zone.isEmpty() ? TimeZone.getDefault().getID() : zone; } - return fromSchedule(crontabCron, cronZone, fixedRateMillis, fixedDelayMillis); + return fromSchedule(crontabCron, cronZone, fixedRateMillis); + } + + /** + * Spring before 5.3 steps day of month {@code *}/n from 0, so it runs on n, 2n, ... Days of week + * already match crontab. Null if both day fields are set, as it then sometimes runs at midnight. + */ + static @Nullable String toCrontabLegacy(final @Nullable String cron) { + if (cron == null) { + return null; + } + final @NotNull String[] fields = cron.trim().split("\\s+", -1); + if (fields.length != 6) { + return cron; + } + if (!isAnyDay(fields[3]) && !isAnyDay(fields[5])) { + return null; + } + final @NotNull StringBuilder daysOfMonth = new StringBuilder(); + for (@NotNull String item : fields[3].split(",", -1)) { + if (item.startsWith("*/")) { + item = item.substring(2) + "-31" + item.substring(1); + } + if (daysOfMonth.length() > 0) { + daysOfMonth.append(','); + } + daysOfMonth.append(item); + } + fields[3] = daysOfMonth.toString(); + return join(fields); + } + + private static boolean isAnyDay(final @NotNull String field) { + return "*".equals(field) || "?".equals(field); } /** * Spring numbers days of week from Monday with 0 or 7 for Sunday, and starts {@code *} on Monday. - * Rewrites them so crontab reads them the same way. + * Rewrites them so crontab reads them the same way. Null for {@code #5}, which Spring 5.3 also + * runs in months without a fifth weekday. */ static @Nullable String toCrontabDaysOfWeek(final @Nullable String cron) { if (cron == null) { @@ -166,6 +205,9 @@ private MonitorConfigUtils() {} for (int i = 0; i < DAY_NAMES.size(); i++) { item = item.replace(DAY_NAMES.get(i), String.valueOf(i == 0 ? 7 : i)); } + if (item.matches(".*#0*5")) { + return null; + } if (item.startsWith("*/")) { item = "1-7" + item.substring(1); } else if (item.startsWith("7-")) { @@ -178,6 +220,10 @@ private MonitorConfigUtils() {} daysOfWeek.append(item); } fields[5] = daysOfWeek.toString(); + return join(fields); + } + + private static @NotNull String join(final @NotNull String[] fields) { final @NotNull StringBuilder result = new StringBuilder(); for (int i = 0; i < fields.length; i++) { if (i > 0) { diff --git a/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt index d6976f7d148..f3e08ac99fa 100644 --- a/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt @@ -9,7 +9,7 @@ class MonitorConfigUtilsTest { @Test fun `cron with fixed seconds drops the seconds field`() { - val config = MonitorConfigUtils.fromSchedule("0 15 10 * * MON-FRI", null, null, null) + val config = MonitorConfigUtils.fromSchedule("0 15 10 * * MON-FRI", null, null) assertThat(config).isNotNull() assertThat(config!!.schedule.type).isEqualTo("crontab") @@ -20,14 +20,14 @@ class MonitorConfigUtilsTest { @Test fun `cron whitespace is normalized`() { - val config = MonitorConfigUtils.fromSchedule(" 30 0 2 * * * ", null, null, null) + val config = MonitorConfigUtils.fromSchedule(" 30 0 2 * * * ", null, null) assertThat(config?.schedule?.value).isEqualTo("0 2 * * *") } @Test fun `cron question mark is converted to asterisk`() { - val config = MonitorConfigUtils.fromSchedule("0 0 9 ? * MON", null, null, null) + val config = MonitorConfigUtils.fromSchedule("0 0 9 ? * MON", null, null) assertThat(config?.schedule?.value).isEqualTo("0 9 * * MON") } @@ -46,33 +46,33 @@ class MonitorConfigUtilsTest { "@HOURLY" to "0 * * * *", ) for ((macro, crontab) in expected) { - assertThat(MonitorConfigUtils.fromSchedule(macro, null, null, null)?.schedule?.value) + assertThat(MonitorConfigUtils.fromSchedule(macro, null, null)?.schedule?.value) .isEqualTo(crontab) } } @Test fun `unknown cron macro returns null`() { - assertThat(MonitorConfigUtils.fromSchedule("@reboot", null, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("@reboot", null, null)).isNull() } @Test fun `cron with variable seconds returns null`() { - assertThat(MonitorConfigUtils.fromSchedule("*/30 * * * * *", null, null, null)).isNull() - assertThat(MonitorConfigUtils.fromSchedule("0,30 * * * * *", null, null, null)).isNull() - assertThat(MonitorConfigUtils.fromSchedule("0-10 * * * * *", null, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("*/30 * * * * *", null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0,30 * * * * *", null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0-10 * * * * *", null, null)).isNull() } @Test fun `cron without 6 fields returns null`() { - assertThat(MonitorConfigUtils.fromSchedule("0 * * * *", null, null, null)).isNull() - assertThat(MonitorConfigUtils.fromSchedule("0 0 * * * * 2030", null, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0 * * * *", null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0 0 * * * * 2030", null, null)).isNull() } @Test fun `cron with both day of month and day of week returns null`() { for (cron in listOf("0 0 9 1-7 * MON", "0 0 9 15 * 1-5", "0 0 9 L * 5L", "0 0 9 1 * 1/2")) { - assertThat(MonitorConfigUtils.fromSchedule(cron, null, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)).isNull() } } @@ -85,7 +85,7 @@ class MonitorConfigUtilsTest { "0 0 9 1 * ?" to "0 9 1 * *", ) for ((cron, crontab) in expected) { - assertThat(MonitorConfigUtils.fromSchedule(cron, null, null, null)?.schedule?.value) + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)?.schedule?.value) .isEqualTo(crontab) } } @@ -110,7 +110,7 @@ class MonitorConfigUtilsTest { "0 0 0 * * 1-7/2", ) for (cron in expected) { - assertThat(MonitorConfigUtils.fromSchedule(cron, null, null, null)?.schedule?.value) + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)?.schedule?.value) .isEqualTo(cron.substringAfter(' ')) } } @@ -147,7 +147,7 @@ class MonitorConfigUtilsTest { "0 0 0 * * 5#0", ) for (cron in rejected) { - assertThat(MonitorConfigUtils.fromSchedule(cron, null, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)).isNull() } } @@ -156,18 +156,18 @@ class MonitorConfigUtilsTest { // Spring and Quartz run only at the value, cronsim steps to the field max for (cron in listOf("0 10-10/2 * * * *", "0 0 0-0/3 * * *", "0 0 0 ? * 4-4/3", "0 0 0 * 6-6/1 *")) { - assertThat(MonitorConfigUtils.fromSchedule(cron, null, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)).isNull() } } @Test fun `disabled cron returns null`() { - assertThat(MonitorConfigUtils.fromSchedule("-", null, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("-", null, null)).isNull() } @Test fun `zone is set for cron`() { - val config = MonitorConfigUtils.fromSchedule("0 0 2 * * *", "Europe/Vienna", null, null) + val config = MonitorConfigUtils.fromSchedule("0 0 2 * * *", "Europe/Vienna", null) assertThat(config?.schedule?.value).isEqualTo("0 2 * * *") assertThat(config?.timezone).isEqualTo("Europe/Vienna") @@ -177,7 +177,7 @@ class MonitorConfigUtilsTest { fun `IANA zones are kept`() { for (zone in listOf("UTC", "GMT", "Etc/GMT+5", "US/Eastern", "America/Argentina/Buenos_Aires")) { - assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", zone, null, null)?.timezone) + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", zone, null)?.timezone) .isEqualTo(zone) } } @@ -195,7 +195,7 @@ class MonitorConfigUtilsTest { "GMT-00:00" to "Etc/GMT", ) for ((zone, timezone) in expected) { - assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", zone, null, null)?.timezone) + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", zone, null)?.timezone) .isEqualTo(timezone) } } @@ -204,18 +204,18 @@ class MonitorConfigUtilsTest { fun `zones Sentry rejects return null`() { for (zone in listOf("GMT+05:30", "GMT+15", "UTC-13", "PST", "IST", "Nowhere/Land", "SystemV/EST5")) { - assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", zone, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", zone, null)).isNull() } } @Test fun `empty zone is not set`() { - assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", "", null, null)?.timezone).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", "", null)?.timezone).isNull() } @Test fun `fixed rate in whole minutes is an interval`() { - val config = MonitorConfigUtils.fromSchedule(null, null, TimeUnit.MINUTES.toMillis(5), null) + val config = MonitorConfigUtils.fromSchedule(null, null, TimeUnit.MINUTES.toMillis(5)) assertThat(config).isNotNull() assertThat(config!!.schedule.type).isEqualTo("interval") @@ -223,14 +223,6 @@ class MonitorConfigUtilsTest { assertThat(config.schedule.unit).isEqualTo("minute") } - @Test - fun `fixed delay in whole minutes is an interval`() { - val config = MonitorConfigUtils.fromSchedule("", null, null, TimeUnit.MINUTES.toMillis(90)) - - assertThat(config?.schedule?.value).isEqualTo("90") - assertThat(config?.schedule?.unit).isEqualTo("minute") - } - @Test fun `period in whole hours or days uses the largest unit`() { val expected = @@ -241,7 +233,7 @@ class MonitorConfigUtilsTest { TimeUnit.DAYS.toMillis(14) to ("14" to "day"), ) for ((millis, interval) in expected) { - val config = MonitorConfigUtils.fromSchedule(null, null, millis, null) + val config = MonitorConfigUtils.fromSchedule(null, null, millis) assertThat(config?.schedule?.value).isEqualTo(interval.first) assertThat(config?.schedule?.unit).isEqualTo(interval.second) } @@ -250,7 +242,7 @@ class MonitorConfigUtilsTest { @Test fun `zone is ignored for intervals`() { val config = - MonitorConfigUtils.fromSchedule(null, "Europe/Vienna", TimeUnit.MINUTES.toMillis(1), null) + MonitorConfigUtils.fromSchedule(null, "Europe/Vienna", TimeUnit.MINUTES.toMillis(1)) assertThat(config?.schedule?.value).isEqualTo("1") assertThat(config?.timezone).isNull() @@ -258,13 +250,13 @@ class MonitorConfigUtilsTest { @Test fun `sub-minute period returns null`() { - assertThat(MonitorConfigUtils.fromSchedule(null, null, 30_000L, null)).isNull() - assertThat(MonitorConfigUtils.fromSchedule(null, null, 0L, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule(null, null, 30_000L)).isNull() + assertThat(MonitorConfigUtils.fromSchedule(null, null, 0L)).isNull() } @Test fun `period not in whole minutes returns null`() { - assertThat(MonitorConfigUtils.fromSchedule(null, null, 90_000L, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule(null, null, 90_000L)).isNull() } @Test @@ -274,7 +266,6 @@ class MonitorConfigUtilsTest { null, null, TimeUnit.MINUTES.toMillis(Int.MAX_VALUE.toLong() + 1), - null, ) ) .isNull() @@ -282,14 +273,13 @@ class MonitorConfigUtilsTest { @Test fun `more than one schedule kind returns null`() { - assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", null, 60_000L, null)).isNull() - assertThat(MonitorConfigUtils.fromSchedule(null, null, 60_000L, 60_000L)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("0 0 2 * * *", null, 60_000L)).isNull() } @Test fun `no schedule returns null`() { - assertThat(MonitorConfigUtils.fromSchedule(null, null, null, null)).isNull() - assertThat(MonitorConfigUtils.fromSchedule("", null, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule(null, null, null)).isNull() + assertThat(MonitorConfigUtils.fromSchedule("", null, null)).isNull() } @Test @@ -307,7 +297,9 @@ class MonitorConfigUtilsTest { "0 0 9 * * *" to "0 9 * * *", ) for ((cron, crontab) in expected) { - assertThat(MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null)?.schedule?.value) + assertThat( + MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null, false)?.schedule?.value + ) .isEqualTo(crontab) } } @@ -317,9 +309,13 @@ class MonitorConfigUtilsTest { val defaultTimeZone = TimeZone.getDefault() TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) try { - assertThat(MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", null, null, null)?.timezone) + assertThat( + MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", null, null, null, false)?.timezone + ) .isEqualTo("Asia/Tokyo") - assertThat(MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", "", null, null)?.timezone) + assertThat( + MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", "", null, null, false)?.timezone + ) .isEqualTo("Asia/Tokyo") } finally { TimeZone.setDefault(defaultTimeZone) @@ -328,7 +324,7 @@ class MonitorConfigUtilsTest { @Test fun `Spring cron zone is converted`() { - val config = MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", "GMT+2", null, null) + val config = MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", "GMT+2", null, null, false) assertThat(config?.schedule?.value).isEqualTo("0 2 * * *") assertThat(config?.timezone).isEqualTo("Etc/GMT-2") @@ -337,24 +333,80 @@ class MonitorConfigUtilsTest { @Test fun `Spring schedules Sentry can't express return null`() { for (cron in listOf("0 0 9 1-7 * MON", "-", "\${my.cron.missing}", "*/30 * * * * *")) { - assertThat(MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null)).isNull() + assertThat(MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null, false)).isNull() } - assertThat(MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", "GMT+05:30", null, null)) + assertThat( + MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", "GMT+05:30", null, null, false) + ) .isNull() - assertThat(MonitorConfigUtils.fromSpringScheduled(null, null, 30_000L, null)).isNull() + assertThat(MonitorConfigUtils.fromSpringScheduled(null, null, 30_000L, null, false)).isNull() } @Test fun `Spring periods are intervals without zone`() { - val rate = MonitorConfigUtils.fromSpringScheduled("", null, TimeUnit.MINUTES.toMillis(5), null) + val rate = + MonitorConfigUtils.fromSpringScheduled("", null, TimeUnit.MINUTES.toMillis(5), null, false) assertThat(rate?.schedule?.type).isEqualTo("interval") assertThat(rate?.schedule?.value).isEqualTo("5") assertThat(rate?.schedule?.unit).isEqualTo("minute") assertThat(rate?.timezone).isNull() + } + + @Test + fun `Spring fixed delay returns null`() { + for (legacy in listOf(false, true)) { + assertThat(MonitorConfigUtils.fromSpringScheduled(null, null, null, 300_000L, legacy)) + .isNull() + assertThat(MonitorConfigUtils.fromSpringScheduled("", null, 300_000L, 300_000L, legacy)) + .isNull() + } + } + + @Test + fun `Spring fifth weekday returns null`() { + assertThat(MonitorConfigUtils.fromSpringScheduled("0 0 9 ? * MON#5", "UTC", null, null, false)) + .isNull() + } + + @Test + fun `legacy Spring cron is rewritten to how crontab reads it`() { + val expected = + mapOf( + "0 0 9 */2 * *" to "0 9 2-31/2 * *", + "0 0 9 */1,15 * ?" to "0 9 1-31/1,15 * *", + "0 0 9 ? * */2" to "0 9 * * */2", + "0 0 9 ? * SUN/2" to "0 9 * * SUN/2", + "0 0 9 ? * 5-7" to "0 9 * * 5-7", + "0 0 9 1-15/3 * *" to "0 9 1-15/3 * *", + ) + for ((cron, crontab) in expected) { + assertThat( + MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null, true)?.schedule?.value + ) + .isEqualTo(crontab) + } + } - val delay = MonitorConfigUtils.fromSpringScheduled(null, null, null, TimeUnit.HOURS.toMillis(2)) - assertThat(delay?.schedule?.value).isEqualTo("2") - assertThat(delay?.schedule?.unit).isEqualTo("hour") + @Test + fun `legacy Spring cron with both day fields returns null`() { + // CronSequenceGenerator then sometimes runs at midnight + for (cron in listOf("0 0 9 1 * */2", "0 0 9 */2 * */3", "0 0 9 */2 * MON")) { + assertThat(MonitorConfigUtils.fromSpringScheduled(cron, "UTC", null, null, true)).isNull() + } + } + + @Test + fun `legacy Spring cron without zone uses the JVM default zone`() { + val defaultTimeZone = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Tokyo")) + try { + assertThat( + MonitorConfigUtils.fromSpringScheduled("0 0 2 * * *", null, null, null, true)?.timezone + ) + .isEqualTo("Asia/Tokyo") + } finally { + TimeZone.setDefault(defaultTimeZone) + } } @Test From 5d745c70b33f217843902ea68ca13fe3f59fba26 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 5 Oct 2026 15:54:08 -0700 Subject: [PATCH 06/38] feat(spring): Send monitor config from @Scheduled with @SentryCheckIn Co-Authored-By: Claude --- CHANGELOG.md | 1 + sentry-spring-7/api/sentry-spring-7.api | 1 + .../sentry/spring7/checkin/SentryCheckIn.java | 8 + .../spring7/checkin/SentryCheckInAdvice.java | 119 ++++++++- .../sentry/spring7/SentryCheckInAdviceTest.kt | 238 +++++++++++++++++- .../api/sentry-spring-jakarta.api | 1 + .../spring/jakarta/checkin/SentryCheckIn.java | 8 + .../jakarta/checkin/SentryCheckInAdvice.java | 119 ++++++++- .../spring/jakarta/SentryCheckInAdviceTest.kt | 238 +++++++++++++++++- sentry-spring/api/sentry-spring.api | 1 + .../sentry/spring/checkin/SentryCheckIn.java | 8 + .../spring/checkin/SentryCheckInAdvice.java | 119 ++++++++- .../sentry/spring/SentryCheckInAdviceTest.kt | 238 +++++++++++++++++- 13 files changed, 1093 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4126d1706dd..17d332f8011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Add `@SentryCheckIn(upsertMonitorConfig = true)` to send a monitor config derived from Spring `@Scheduled` with check-ins, so Sentry can create the monitor from code ([#6215](https://github.com/getsentry/sentry-java/pull/6215)) - Report the cellular network technology generation in `device.connection_effective_type`, for example `4g` or `5g` ([#6146](https://github.com/getsentry/sentry-java/pull/6146)) ## 8.59.0 diff --git a/sentry-spring-7/api/sentry-spring-7.api b/sentry-spring-7/api/sentry-spring-7.api index c9250b550fd..c5aaf0a5d1b 100644 --- a/sentry-spring-7/api/sentry-spring-7.api +++ b/sentry-spring-7/api/sentry-spring-7.api @@ -136,6 +136,7 @@ public final class io/sentry/spring7/cache/SentryCacheWrapper : org/springframew public abstract interface annotation class io/sentry/spring7/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; } 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..f840fa7c44b 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,14 @@ */ boolean heartbeat() default false; + /** + * Set to true to send the schedule from the method's {@code @Scheduled}, so Sentry creates or + * updates the monitor. + * + * @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 e58e0e9dd97..920ef6a707d 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,21 @@ 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.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; import org.jetbrains.annotations.ApiStatus; @@ -20,7 +28,10 @@ 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.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringValueResolver; @@ -31,10 +42,19 @@ @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 final @NotNull IScopes scopes; private @Nullable StringValueResolver resolver; + private final @NotNull Map monitorConfigs = + new ConcurrentHashMap<>(); + public SentryCheckInAdvice() { this(ScopesAdapter.getInstance()); } @@ -87,6 +107,11 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl return invocation.proceed(); } + final @Nullable MonitorConfig monitorConfig = + !isHeartbeatOnly && checkInAnnotation.upsertMonitorConfig() + ? monitorConfig(mostSpecificMethod) + : null; + try (final @NotNull ISentryLifecycleToken ignored = scopes.forkedScopes("SentryCheckInAdvice").makeCurrent()) { TracingUtils.startNewTrace(scopes); @@ -98,7 +123,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 +140,96 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } } + 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.findMergedRepeatableAnnotations(method, Scheduled.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 (Throwable e) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Could not derive a monitor config from @Scheduled for method annotated with @SentryCheckIn.", + 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 for @SentryCheckIn 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/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/SentryCheckInAdviceTest.kt index e15affdbdd5..8f0921bd78e 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,8 @@ class SentryCheckInAdviceTest { @Autowired lateinit var sampleServiceSpringProperties: SampleServiceSpringProperties + @Autowired lateinit var sampleServiceScheduled: SampleServiceScheduled + @Autowired lateinit var scopes: IScopes val lifecycleToken = mock() @@ -74,6 +91,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 +246,150 @@ 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 defaults to false and sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.defaultNoConfig() }) + } + + @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 +403,8 @@ class SentryCheckInAdviceTest { @Bean open fun sampleServiceSpringProperties() = SampleServiceSpringProperties() + @Bean open fun sampleServiceScheduled() = SampleServiceScheduled() + @Bean open fun scopes(): IScopes { val scopes = mock() @@ -291,6 +455,78 @@ 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("default_no_config") + @Scheduled(cron = "0 0 1 * * *") + open fun defaultNoConfig() {} + + @SentryCheckIn("heartbeat", heartbeat = true, upsertMonitorConfig = true) + @Scheduled(cron = "0 0 1 * * *") + open fun heartbeat() {} + } + class MyPropertyPlaceholderConfigurer : PropertySourcesPlaceholderConfigurer() { override fun doProcessProperties( diff --git a/sentry-spring-jakarta/api/sentry-spring-jakarta.api b/sentry-spring-jakarta/api/sentry-spring-jakarta.api index 24b9af7e14b..aa1dae669ba 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -136,6 +136,7 @@ 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; } 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..4eff0b0edf4 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,14 @@ */ boolean heartbeat() default false; + /** + * Set to true to send the schedule from the method's {@code @Scheduled}, so Sentry creates or + * updates the monitor. + * + * @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..221bb1bed93 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,21 @@ 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.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; import org.jetbrains.annotations.ApiStatus; @@ -20,7 +28,10 @@ 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.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringValueResolver; @@ -31,10 +42,19 @@ @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 final @NotNull IScopes scopes; private @Nullable StringValueResolver resolver; + private final @NotNull Map monitorConfigs = + new ConcurrentHashMap<>(); + public SentryCheckInAdvice() { this(ScopesAdapter.getInstance()); } @@ -87,6 +107,11 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl return invocation.proceed(); } + final @Nullable MonitorConfig monitorConfig = + !isHeartbeatOnly && checkInAnnotation.upsertMonitorConfig() + ? monitorConfig(mostSpecificMethod) + : null; + try (final @NotNull ISentryLifecycleToken ignored = scopes.forkedScopes("SentryCheckInAdvice").makeCurrent()) { TracingUtils.startNewTrace(scopes); @@ -98,7 +123,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 +140,96 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } } + 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.findMergedRepeatableAnnotations(method, Scheduled.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 (Throwable e) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Could not derive a monitor config from @Scheduled for method annotated with @SentryCheckIn.", + 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 for @SentryCheckIn 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/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/SentryCheckInAdviceTest.kt index c6edd834530..c50ca7979a6 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,8 @@ class SentryCheckInAdviceTest { @Autowired lateinit var sampleServiceSpringProperties: SampleServiceSpringProperties + @Autowired lateinit var sampleServiceScheduled: SampleServiceScheduled + @Autowired lateinit var scopes: IScopes val lifecycleToken = mock() @@ -74,6 +91,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 +246,150 @@ 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 defaults to false and sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.defaultNoConfig() }) + } + + @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 +403,8 @@ class SentryCheckInAdviceTest { @Bean open fun sampleServiceSpringProperties() = SampleServiceSpringProperties() + @Bean open fun sampleServiceScheduled() = SampleServiceScheduled() + @Bean open fun scopes(): IScopes { val scopes = mock() @@ -291,6 +455,78 @@ 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("default_no_config") + @Scheduled(cron = "0 0 1 * * *") + open fun defaultNoConfig() {} + + @SentryCheckIn("heartbeat", heartbeat = true, upsertMonitorConfig = true) + @Scheduled(cron = "0 0 1 * * *") + open fun heartbeat() {} + } + class MyPropertyPlaceholderConfigurer : PropertySourcesPlaceholderConfigurer() { override fun doProcessProperties( diff --git a/sentry-spring/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api index 4e1bea84288..8b8bfa69c70 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -134,6 +134,7 @@ 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; } 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..e3cbb2a7c7b 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,14 @@ */ boolean heartbeat() default false; + /** + * Set to true to send the schedule from the method's {@code @Scheduled}, so Sentry creates or + * updates the monitor. + * + * @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..83594da523f 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,21 @@ 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.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import org.aopalliance.intercept.MethodInterceptor; import org.aopalliance.intercept.MethodInvocation; import org.jetbrains.annotations.ApiStatus; @@ -20,7 +28,10 @@ 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.util.ClassUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.StringValueResolver; @@ -31,10 +42,19 @@ @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 final @NotNull IScopes scopes; private @Nullable StringValueResolver resolver; + private final @NotNull Map monitorConfigs = + new ConcurrentHashMap<>(); + public SentryCheckInAdvice() { this(ScopesAdapter.getInstance()); } @@ -90,6 +110,11 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl return invocation.proceed(); } + final @Nullable MonitorConfig monitorConfig = + !isHeartbeatOnly && checkInAnnotation.upsertMonitorConfig() + ? monitorConfig(mostSpecificMethod) + : null; + try (final @NotNull ISentryLifecycleToken ignored = scopes.forkedScopes("SentryCheckInAdvice").makeCurrent()) { TracingUtils.startNewTrace(scopes); @@ -101,7 +126,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 +143,96 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } } + 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.findMergedRepeatableAnnotations(method, Scheduled.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 (Throwable e) { + scopes + .getOptions() + .getLogger() + .log( + SentryLevel.WARNING, + "Could not derive a monitor config from @Scheduled for method annotated with @SentryCheckIn.", + 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 for @SentryCheckIn 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/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt index 2c5e7d0cd06..62bab3973d2 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,8 @@ class SentryCheckInAdviceTest { @Autowired lateinit var sampleServiceSpringProperties: SampleServiceSpringProperties + @Autowired lateinit var sampleServiceScheduled: SampleServiceScheduled + @Autowired lateinit var scopes: IScopes val lifecycleToken = mock() @@ -75,6 +92,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 +247,150 @@ 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 defaults to false and sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.defaultNoConfig() }) + } + + @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 +404,8 @@ class SentryCheckInAdviceTest { @Bean open fun sampleServiceSpringProperties() = SampleServiceSpringProperties() + @Bean open fun sampleServiceScheduled() = SampleServiceScheduled() + @Bean open fun scopes(): IScopes { val scopes = mock() @@ -292,6 +456,78 @@ 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("default_no_config") + @Scheduled(cron = "0 0 1 * * *") + open fun defaultNoConfig() {} + + @SentryCheckIn("heartbeat", heartbeat = true, upsertMonitorConfig = true) + @Scheduled(cron = "0 0 1 * * *") + open fun heartbeat() {} + } + class MyPropertyPlaceholderConfigurer : PropertySourcesPlaceholderConfigurer() { override fun doProcessProperties( From bcd5682e0295f43efb14b9dd86e2c1e40093bbdd Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 5 Oct 2026 16:07:25 -0700 Subject: [PATCH 07/38] feat(spring): Send @Scheduled monitor config by default Co-Authored-By: Claude --- CHANGELOG.md | 2 +- .../sentry/spring7/checkin/SentryCheckIn.java | 11 +++-- .../sentry/spring7/SentryCheckInAdviceTest.kt | 46 ++++++++----------- .../spring/jakarta/checkin/SentryCheckIn.java | 11 +++-- .../spring/jakarta/SentryCheckInAdviceTest.kt | 46 ++++++++----------- .../sentry/spring/checkin/SentryCheckIn.java | 11 +++-- .../sentry/spring/SentryCheckInAdviceTest.kt | 46 ++++++++----------- 7 files changed, 82 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17d332f8011..10f53cef310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Add `@SentryCheckIn(upsertMonitorConfig = true)` to send a monitor config derived from Spring `@Scheduled` with check-ins, so Sentry can create the monitor from code ([#6215](https://github.com/getsentry/sentry-java/pull/6215)) +- Send a monitor config derived from Spring `@Scheduled` with `@SentryCheckIn` check-ins by default, so Sentry creates or updates the monitor from code. Set `@SentryCheckIn(upsertMonitorConfig = false)` to turn this off ([#6215](https://github.com/getsentry/sentry-java/pull/6215)) - Report the cellular network technology generation in `device.connection_effective_type`, for example `4g` or `5g` ([#6146](https://github.com/getsentry/sentry-java/pull/6146)) ## 8.59.0 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 f840fa7c44b..09c8e6f290d 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 @@ -30,12 +30,15 @@ boolean heartbeat() default false; /** - * Set to true to send the schedule from the method's {@code @Scheduled}, so Sentry creates or - * updates the monitor. + * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so + * Sentry creates or updates the monitor. On by default. Set to false to manage the monitor's + * schedule in Sentry instead, for example if its timezone differs from the JVM's. * - * @return true to send a monitor config, false by default + *

Heartbeat check-ins never send a monitor config. + * + * @return true to send a monitor config, true by default */ - boolean upsertMonitorConfig() default false; + boolean upsertMonitorConfig() default true; /** * Monitor slug. If not set, no check-in will be sent. 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 8f0921bd78e..a071ba293ca 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 @@ -366,8 +366,8 @@ class SentryCheckInAdviceTest { } @Test - fun `upsertMonitorConfig defaults to false and sends no monitor config`() { - assertNull(inProgressMonitorConfig { sampleServiceScheduled.defaultNoConfig() }) + fun `upsertMonitorConfig false sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDisabled() }) } @Test @@ -457,72 +457,66 @@ class SentryCheckInAdviceTest { open class SampleServiceScheduled { - @SentryCheckIn("cron_zone", upsertMonitorConfig = true) + @SentryCheckIn("cron_zone") @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") @Scheduled(cron = "0 0 2 * * *") open fun cron() {} - @SentryCheckIn("cron_properties", upsertMonitorConfig = true) + @SentryCheckIn("cron_properties") @Scheduled(cron = "\${my.cron.schedule}", zone = "\${my.cron.zone}") open fun cronFromProperties() {} - @SentryCheckIn("fixed_rate_hours", upsertMonitorConfig = true) + @SentryCheckIn("fixed_rate_hours") @Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS) open fun fixedRateHours() {} - @SentryCheckIn("fixed_rate_iso", upsertMonitorConfig = true) + @SentryCheckIn("fixed_rate_iso") @Scheduled(fixedRateString = "PT10M") open fun fixedRateIso() {} - @SentryCheckIn("fixed_rate_simple", upsertMonitorConfig = true) + @SentryCheckIn("fixed_rate_simple") @Scheduled(fixedRateString = "5m") open fun fixedRateSimpleDuration() {} - @SentryCheckIn("fixed_delay", upsertMonitorConfig = true) - @Scheduled(fixedDelay = 300_000) - open fun fixedDelay() {} + @SentryCheckIn("fixed_delay") @Scheduled(fixedDelay = 300_000) open fun fixedDelay() {} - @SentryCheckIn("fixed_delay_iso", upsertMonitorConfig = true) + @SentryCheckIn("fixed_delay_iso") @Scheduled(fixedDelayString = "PT10M") open fun fixedDelayIso() {} - @SentryCheckIn("disabled_cron", upsertMonitorConfig = true) - @Scheduled(cron = "-") - open fun disabledCron() {} + @SentryCheckIn("disabled_cron") @Scheduled(cron = "-") open fun disabledCron() {} - @SentryCheckIn("cron_empty_zone", upsertMonitorConfig = true) + @SentryCheckIn("cron_empty_zone") @Scheduled(cron = "0 0 3 * * *", zone = "\${my.cron.empty.zone}") open fun cronWithEmptyZone() {} - @SentryCheckIn("cron_zone_defaults", upsertMonitorConfig = true) + @SentryCheckIn("cron_zone_defaults") @Scheduled(cron = "0 0 4 * * *", zone = "Europe/Vienna") open fun cronWithZoneAndDefaults() {} - @SentryCheckIn("schedules_container", upsertMonitorConfig = true) + @SentryCheckIn("schedules_container") @Schedules(Scheduled(cron = "0 0 1 * * *"), Scheduled(cron = "0 0 13 * * *")) open fun schedulesContainer() {} - @SentryCheckIn("unresolvable_cron", upsertMonitorConfig = true) + @SentryCheckIn("unresolvable_cron") @Scheduled(cron = "\${my.cron.missing}") open fun unresolvableCron() {} - @SentryCheckIn("derivation_throws", upsertMonitorConfig = true) + @SentryCheckIn("derivation_throws") @Scheduled(cron = "\${my.cron.exception.property}") open fun derivationThrows() = 1 - @SentryCheckIn("multiple", upsertMonitorConfig = true) + @SentryCheckIn("multiple") @Scheduled(cron = "0 0 1 * * *") @Scheduled(cron = "0 0 13 * * *") open fun multipleSchedules() {} - @SentryCheckIn("default_no_config") + @SentryCheckIn("upsert_disabled", upsertMonitorConfig = false) @Scheduled(cron = "0 0 1 * * *") - open fun defaultNoConfig() {} + open fun upsertDisabled() {} - @SentryCheckIn("heartbeat", heartbeat = true, upsertMonitorConfig = true) + @SentryCheckIn("heartbeat", heartbeat = true) @Scheduled(cron = "0 0 1 * * *") open fun heartbeat() {} } 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 4eff0b0edf4..54bd182976b 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 @@ -30,12 +30,15 @@ boolean heartbeat() default false; /** - * Set to true to send the schedule from the method's {@code @Scheduled}, so Sentry creates or - * updates the monitor. + * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so + * Sentry creates or updates the monitor. On by default. Set to false to manage the monitor's + * schedule in Sentry instead, for example if its timezone differs from the JVM's. * - * @return true to send a monitor config, false by default + *

Heartbeat check-ins never send a monitor config. + * + * @return true to send a monitor config, true by default */ - boolean upsertMonitorConfig() default false; + boolean upsertMonitorConfig() default true; /** * Monitor slug. If not set, no check-in will be sent. 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 c50ca7979a6..7a7e5b81d04 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 @@ -366,8 +366,8 @@ class SentryCheckInAdviceTest { } @Test - fun `upsertMonitorConfig defaults to false and sends no monitor config`() { - assertNull(inProgressMonitorConfig { sampleServiceScheduled.defaultNoConfig() }) + fun `upsertMonitorConfig false sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDisabled() }) } @Test @@ -457,72 +457,66 @@ class SentryCheckInAdviceTest { open class SampleServiceScheduled { - @SentryCheckIn("cron_zone", upsertMonitorConfig = true) + @SentryCheckIn("cron_zone") @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") @Scheduled(cron = "0 0 2 * * *") open fun cron() {} - @SentryCheckIn("cron_properties", upsertMonitorConfig = true) + @SentryCheckIn("cron_properties") @Scheduled(cron = "\${my.cron.schedule}", zone = "\${my.cron.zone}") open fun cronFromProperties() {} - @SentryCheckIn("fixed_rate_hours", upsertMonitorConfig = true) + @SentryCheckIn("fixed_rate_hours") @Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS) open fun fixedRateHours() {} - @SentryCheckIn("fixed_rate_iso", upsertMonitorConfig = true) + @SentryCheckIn("fixed_rate_iso") @Scheduled(fixedRateString = "PT10M") open fun fixedRateIso() {} - @SentryCheckIn("fixed_rate_simple", upsertMonitorConfig = true) + @SentryCheckIn("fixed_rate_simple") @Scheduled(fixedRateString = "5m") open fun fixedRateSimpleDuration() {} - @SentryCheckIn("fixed_delay", upsertMonitorConfig = true) - @Scheduled(fixedDelay = 300_000) - open fun fixedDelay() {} + @SentryCheckIn("fixed_delay") @Scheduled(fixedDelay = 300_000) open fun fixedDelay() {} - @SentryCheckIn("fixed_delay_iso", upsertMonitorConfig = true) + @SentryCheckIn("fixed_delay_iso") @Scheduled(fixedDelayString = "PT10M") open fun fixedDelayIso() {} - @SentryCheckIn("disabled_cron", upsertMonitorConfig = true) - @Scheduled(cron = "-") - open fun disabledCron() {} + @SentryCheckIn("disabled_cron") @Scheduled(cron = "-") open fun disabledCron() {} - @SentryCheckIn("cron_empty_zone", upsertMonitorConfig = true) + @SentryCheckIn("cron_empty_zone") @Scheduled(cron = "0 0 3 * * *", zone = "\${my.cron.empty.zone}") open fun cronWithEmptyZone() {} - @SentryCheckIn("cron_zone_defaults", upsertMonitorConfig = true) + @SentryCheckIn("cron_zone_defaults") @Scheduled(cron = "0 0 4 * * *", zone = "Europe/Vienna") open fun cronWithZoneAndDefaults() {} - @SentryCheckIn("schedules_container", upsertMonitorConfig = true) + @SentryCheckIn("schedules_container") @Schedules(Scheduled(cron = "0 0 1 * * *"), Scheduled(cron = "0 0 13 * * *")) open fun schedulesContainer() {} - @SentryCheckIn("unresolvable_cron", upsertMonitorConfig = true) + @SentryCheckIn("unresolvable_cron") @Scheduled(cron = "\${my.cron.missing}") open fun unresolvableCron() {} - @SentryCheckIn("derivation_throws", upsertMonitorConfig = true) + @SentryCheckIn("derivation_throws") @Scheduled(cron = "\${my.cron.exception.property}") open fun derivationThrows() = 1 - @SentryCheckIn("multiple", upsertMonitorConfig = true) + @SentryCheckIn("multiple") @Scheduled(cron = "0 0 1 * * *") @Scheduled(cron = "0 0 13 * * *") open fun multipleSchedules() {} - @SentryCheckIn("default_no_config") + @SentryCheckIn("upsert_disabled", upsertMonitorConfig = false) @Scheduled(cron = "0 0 1 * * *") - open fun defaultNoConfig() {} + open fun upsertDisabled() {} - @SentryCheckIn("heartbeat", heartbeat = true, upsertMonitorConfig = true) + @SentryCheckIn("heartbeat", heartbeat = true) @Scheduled(cron = "0 0 1 * * *") open fun heartbeat() {} } 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 e3cbb2a7c7b..fcd53debaeb 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 @@ -30,12 +30,15 @@ boolean heartbeat() default false; /** - * Set to true to send the schedule from the method's {@code @Scheduled}, so Sentry creates or - * updates the monitor. + * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so + * Sentry creates or updates the monitor. On by default. Set to false to manage the monitor's + * schedule in Sentry instead, for example if its timezone differs from the JVM's. * - * @return true to send a monitor config, false by default + *

Heartbeat check-ins never send a monitor config. + * + * @return true to send a monitor config, true by default */ - boolean upsertMonitorConfig() default false; + boolean upsertMonitorConfig() default true; /** * Monitor slug. If not set, no check-in will be sent. 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 62bab3973d2..b6e8a4e3de2 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt @@ -367,8 +367,8 @@ class SentryCheckInAdviceTest { } @Test - fun `upsertMonitorConfig defaults to false and sends no monitor config`() { - assertNull(inProgressMonitorConfig { sampleServiceScheduled.defaultNoConfig() }) + fun `upsertMonitorConfig false sends no monitor config`() { + assertNull(inProgressMonitorConfig { sampleServiceScheduled.upsertDisabled() }) } @Test @@ -458,72 +458,66 @@ class SentryCheckInAdviceTest { open class SampleServiceScheduled { - @SentryCheckIn("cron_zone", upsertMonitorConfig = true) + @SentryCheckIn("cron_zone") @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") @Scheduled(cron = "0 0 2 * * *") open fun cron() {} - @SentryCheckIn("cron_properties", upsertMonitorConfig = true) + @SentryCheckIn("cron_properties") @Scheduled(cron = "\${my.cron.schedule}", zone = "\${my.cron.zone}") open fun cronFromProperties() {} - @SentryCheckIn("fixed_rate_hours", upsertMonitorConfig = true) + @SentryCheckIn("fixed_rate_hours") @Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS) open fun fixedRateHours() {} - @SentryCheckIn("fixed_rate_iso", upsertMonitorConfig = true) + @SentryCheckIn("fixed_rate_iso") @Scheduled(fixedRateString = "PT10M") open fun fixedRateIso() {} - @SentryCheckIn("fixed_rate_simple", upsertMonitorConfig = true) + @SentryCheckIn("fixed_rate_simple") @Scheduled(fixedRateString = "5m") open fun fixedRateSimpleDuration() {} - @SentryCheckIn("fixed_delay", upsertMonitorConfig = true) - @Scheduled(fixedDelay = 300_000) - open fun fixedDelay() {} + @SentryCheckIn("fixed_delay") @Scheduled(fixedDelay = 300_000) open fun fixedDelay() {} - @SentryCheckIn("fixed_delay_iso", upsertMonitorConfig = true) + @SentryCheckIn("fixed_delay_iso") @Scheduled(fixedDelayString = "PT10M") open fun fixedDelayIso() {} - @SentryCheckIn("disabled_cron", upsertMonitorConfig = true) - @Scheduled(cron = "-") - open fun disabledCron() {} + @SentryCheckIn("disabled_cron") @Scheduled(cron = "-") open fun disabledCron() {} - @SentryCheckIn("cron_empty_zone", upsertMonitorConfig = true) + @SentryCheckIn("cron_empty_zone") @Scheduled(cron = "0 0 3 * * *", zone = "\${my.cron.empty.zone}") open fun cronWithEmptyZone() {} - @SentryCheckIn("cron_zone_defaults", upsertMonitorConfig = true) + @SentryCheckIn("cron_zone_defaults") @Scheduled(cron = "0 0 4 * * *", zone = "Europe/Vienna") open fun cronWithZoneAndDefaults() {} - @SentryCheckIn("schedules_container", upsertMonitorConfig = true) + @SentryCheckIn("schedules_container") @Schedules(Scheduled(cron = "0 0 1 * * *"), Scheduled(cron = "0 0 13 * * *")) open fun schedulesContainer() {} - @SentryCheckIn("unresolvable_cron", upsertMonitorConfig = true) + @SentryCheckIn("unresolvable_cron") @Scheduled(cron = "\${my.cron.missing}") open fun unresolvableCron() {} - @SentryCheckIn("derivation_throws", upsertMonitorConfig = true) + @SentryCheckIn("derivation_throws") @Scheduled(cron = "\${my.cron.exception.property}") open fun derivationThrows() = 1 - @SentryCheckIn("multiple", upsertMonitorConfig = true) + @SentryCheckIn("multiple") @Scheduled(cron = "0 0 1 * * *") @Scheduled(cron = "0 0 13 * * *") open fun multipleSchedules() {} - @SentryCheckIn("default_no_config") + @SentryCheckIn("upsert_disabled", upsertMonitorConfig = false) @Scheduled(cron = "0 0 1 * * *") - open fun defaultNoConfig() {} + open fun upsertDisabled() {} - @SentryCheckIn("heartbeat", heartbeat = true, upsertMonitorConfig = true) + @SentryCheckIn("heartbeat", heartbeat = true) @Scheduled(cron = "0 0 1 * * *") open fun heartbeat() {} } From 6f5ad4e207b188fd341bc5002b58c252c6f9accc Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 5 Oct 2026 16:30:30 -0700 Subject: [PATCH 08/38] ref(spring): Shorten upsertMonitorConfig Javadoc Co-Authored-By: Claude --- .../src/main/java/io/sentry/spring7/checkin/SentryCheckIn.java | 2 +- .../java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java | 2 +- .../src/main/java/io/sentry/spring/checkin/SentryCheckIn.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 09c8e6f290d..38491436786 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 @@ -32,7 +32,7 @@ /** * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so * Sentry creates or updates the monitor. On by default. Set to false to manage the monitor's - * schedule in Sentry instead, for example if its timezone differs from the JVM's. + * schedule in Sentry instead. * *

Heartbeat check-ins never send a monitor config. * 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 54bd182976b..5e180daa0e6 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 @@ -32,7 +32,7 @@ /** * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so * Sentry creates or updates the monitor. On by default. Set to false to manage the monitor's - * schedule in Sentry instead, for example if its timezone differs from the JVM's. + * schedule in Sentry instead. * *

Heartbeat check-ins never send a monitor config. * 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 fcd53debaeb..03df99385c2 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 @@ -32,7 +32,7 @@ /** * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so * Sentry creates or updates the monitor. On by default. Set to false to manage the monitor's - * schedule in Sentry instead, for example if its timezone differs from the JVM's. + * schedule in Sentry instead. * *

Heartbeat check-ins never send a monitor config. * From d758789134152e23dd2cfd4f443c2137e5b510fe Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 5 Oct 2026 17:43:42 -0700 Subject: [PATCH 09/38] fix(core): Keep Sunday-only day of week ranges SUN-SUN was rewritten to 0-7, which crontab reads as every day. Co-Authored-By: Claude --- sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java | 3 +++ sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt | 2 ++ 2 files changed, 5 insertions(+) diff --git a/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java index fd4b2675690..1e033d335e3 100644 --- a/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java +++ b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java @@ -210,6 +210,9 @@ private static boolean isAnyDay(final @NotNull String field) { } if (item.startsWith("*/")) { item = "1-7" + item.substring(1); + } else if (item.startsWith("7-7")) { + // Sunday only; 0-7 would be every day + item = "7" + item.substring(3); } else if (item.startsWith("7-")) { // Sunday starting a range is 0 in Spring item = "0" + item.substring(1); diff --git a/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt index f3e08ac99fa..69b8f8833ed 100644 --- a/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt @@ -291,6 +291,8 @@ class MonitorConfigUtilsTest { "0 0 9 ? * SUN/2" to "0 9 * * 7/2", "0 0 9 ? * SUN-TUE,fri-sun" to "0 9 * * 0-2,5-7", "0 0 9 ? * 7-2" to "0 9 * * 0-2", + "0 0 9 ? * SUN-SUN" to "0 9 * * 7", + "0 0 9 ? * 7-7/2" to "0 9 * * 7/2", "0 0 9 ? * 0,7" to "0 9 * * 0,7", "0 0 9 ? * FRIL" to "0 9 * * 5L", "0 0 9 ? * MON#2" to "0 9 * * 1#2", From d903a79dbe34a15948084f0cd050c87d5c155b1d Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 5 Oct 2026 17:43:45 -0700 Subject: [PATCH 10/38] fix(spring): Only catch runtime exceptions when deriving monitor config Co-Authored-By: Claude --- .../java/io/sentry/spring7/checkin/SentryCheckInAdvice.java | 2 +- .../io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java | 2 +- .../main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 920ef6a707d..3051acbb607 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 @@ -171,7 +171,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl periodMillis(scheduled.fixedRate(), scheduled.fixedRateString(), timeUnit), periodMillis(scheduled.fixedDelay(), scheduled.fixedDelayString(), timeUnit), LEGACY_CRON_PARSER); - } catch (Throwable e) { + } catch (RuntimeException e) { scopes .getOptions() .getLogger() 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 221bb1bed93..1260e9ba05a 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 @@ -171,7 +171,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl periodMillis(scheduled.fixedRate(), scheduled.fixedRateString(), timeUnit), periodMillis(scheduled.fixedDelay(), scheduled.fixedDelayString(), timeUnit), LEGACY_CRON_PARSER); - } catch (Throwable e) { + } catch (RuntimeException e) { scopes .getOptions() .getLogger() 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 83594da523f..202bd30e56a 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 @@ -174,7 +174,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl periodMillis(scheduled.fixedRate(), scheduled.fixedRateString(), timeUnit), periodMillis(scheduled.fixedDelay(), scheduled.fixedDelayString(), timeUnit), LEGACY_CRON_PARSER); - } catch (Throwable e) { + } catch (RuntimeException e) { scopes .getOptions() .getLogger() From 5db58a4ac5000ec920505be72814fbcb7ec20140 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 15 Oct 2026 12:02:32 -0700 Subject: [PATCH 11/38] ref(spring): Shorten upsertMonitorConfig Javadoc Co-Authored-By: Claude --- .../main/java/io/sentry/spring7/checkin/SentryCheckIn.java | 4 ++-- .../java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java | 4 ++-- .../src/main/java/io/sentry/spring/checkin/SentryCheckIn.java | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) 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 38491436786..92bd8046ae7 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 @@ -31,8 +31,8 @@ /** * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so - * Sentry creates or updates the monitor. On by default. Set to false to manage the monitor's - * schedule in Sentry instead. + * Sentry creates or updates the monitor. Set to false to manage the monitor's schedule in Sentry + * instead. * *

Heartbeat check-ins never send a monitor config. * 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 5e180daa0e6..6eb1786bae8 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 @@ -31,8 +31,8 @@ /** * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so - * Sentry creates or updates the monitor. On by default. Set to false to manage the monitor's - * schedule in Sentry instead. + * Sentry creates or updates the monitor. Set to false to manage the monitor's schedule in Sentry + * instead. * *

Heartbeat check-ins never send a monitor config. * 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 03df99385c2..e3fbb018511 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 @@ -31,8 +31,8 @@ /** * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so - * Sentry creates or updates the monitor. On by default. Set to false to manage the monitor's - * schedule in Sentry instead. + * Sentry creates or updates the monitor. Set to false to manage the monitor's schedule in Sentry + * instead. * *

Heartbeat check-ins never send a monitor config. * From 1ca0891e3928fdea843a2429a88956d4cf6b4f8e Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:56:54 -0700 Subject: [PATCH 12/38] feat(spring-jakarta): Monitor all @Scheduled methods with sentry.monitor-scheduled-tasks Adds sentry.monitor-scheduled-tasks and sentry.exclude-scheduled-tasks to Spring Boot 3. Methods without @SentryCheckIn get a slug from the class and method name and send their @Scheduled schedule as the monitor config. Co-Authored-By: Claude --- .../api/sentry-spring-boot-jakarta.api | 4 + .../boot/jakarta/SentryAutoConfiguration.java | 29 +++ .../spring/boot/jakarta/SentryProperties.java | 25 ++ .../jakarta/SentryAutoConfigurationTest.kt | 58 +++++ .../api/sentry-spring-jakarta.api | 6 + .../jakarta/checkin/SentryCheckInAdvice.java | 66 +++++ .../SentryScheduledCheckInPointcut.java | 55 ++++ .../checkin/SentryScheduledCheckInTest.kt | 238 ++++++++++++++++++ 8 files changed, 481 insertions(+) create mode 100644 sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInPointcut.java create mode 100644 sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInTest.kt 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 57ea90133ba..9b9c46158bc 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 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 c47c7e76340..aaa91afa424 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; @@ -49,11 +51,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; @@ -73,6 +80,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; @@ -309,6 +317,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 485d5838d2b..c7241a9466e 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. */ 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 31876882c4d..1217bbfea40 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 @@ -20,6 +20,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 @@ -51,6 +52,7 @@ import io.sentry.transport.apache.ApacheHttpClientTransportFactory 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 @@ -58,9 +60,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 @@ -71,6 +76,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 @@ -85,6 +91,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 @@ -1089,6 +1097,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 @@ -1308,6 +1354,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 aa1dae669ba..865b9e9d424 100644 --- a/sentry-spring-jakarta/api/sentry-spring-jakarta.api +++ b/sentry-spring-jakarta/api/sentry-spring-jakarta.api @@ -143,6 +143,7 @@ public abstract interface annotation class io/sentry/spring/jakarta/checkin/Sent 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 } @@ -163,6 +164,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/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 1260e9ba05a..535ec1ad22f 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 @@ -15,12 +15,16 @@ 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; @@ -48,8 +52,14 @@ public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueReso "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 = @@ -60,7 +70,16 @@ public SentryCheckInAdvice() { } 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 @@ -72,6 +91,14 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl SentryCheckIn checkInAnnotation = AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); if (checkInAnnotation == null) { + if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) { + return captureCheckIns( + invocation, + scheduledTaskSlug( + scheduledTaskName(invocation.getThis().getClass(), mostSpecificMethod)), + false, + monitorConfig(mostSpecificMethod)); + } return invocation.proceed(); } @@ -111,7 +138,15 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl !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); @@ -140,6 +175,37 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } } + static boolean isScheduled(final @NotNull Method method) { + return !AnnotatedElementUtils.findMergedRepeatableAnnotations(method, Scheduled.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))) 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..b7d31f93a45 --- /dev/null +++ b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInPointcut.java @@ -0,0 +1,55 @@ +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.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.Experimental +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 + if (Modifier.isFinal(userClass.getModifiers()) || Modifier.isFinal(method.getModifiers())) { + return false; + } + final @NotNull Method specificMethod = AopUtils.getMostSpecificMethod(method, userClass); + if (!SentryCheckInAdvice.isScheduled(specificMethod) + || AnnotationUtils.findAnnotation(specificMethod, SentryCheckIn.class) != 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/checkin/SentryScheduledCheckInTest.kt b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInTest.kt new file mode 100644 index 00000000000..ee180f54b20 --- /dev/null +++ b/sentry-spring-jakarta/src/test/kotlin/io/sentry/spring/jakarta/checkin/SentryScheduledCheckInTest.kt @@ -0,0 +1,238 @@ +package io.sentry.spring.jakarta.checkin + +import io.sentry.CheckIn +import io.sentry.CheckInStatus +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +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.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.aop.Advisor +import org.springframework.aop.Pointcut +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 + +@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" + + @Autowired lateinit var jobs: Jobs + + @Autowired lateinit var excludedJobs: ExcludedJobs + + @Autowired lateinit var finalJobs: FinalJobs + + @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 `fixed delay is monitored without config`() { + val checkIns = capture { jobs.fixedDelay() } + assertEquals(2, checkIns.size) + 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 }) + } + + @Test + fun `methods without @Scheduled are not monitored`() { + jobs.notScheduled() + 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 `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"), + ), + ) + } + + 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 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() {} + + @Scheduled(cron = "0 0 2 * * *") open fun excluded() {} + + open fun notScheduled() {} + } + + open class ExcludedJobs { + @Scheduled(cron = "0 0 2 * * *") open fun run() {} + } + + class FinalJobs { + @Scheduled(cron = "0 0 2 * * *") fun run() {} + } +} From 358f88a34dc7d9e499c5f7ab5a4e40999dce18f6 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:57:12 -0700 Subject: [PATCH 13/38] changelog Co-Authored-By: Claude --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f53cef310..01c3ec23dd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- Monitor every Spring `@Scheduled` method with Sentry Crons by setting `sentry.monitor-scheduled-tasks=true`, without adding `@SentryCheckIn` to each method. Exclude methods with `sentry.exclude-scheduled-tasks` ([#6275](https://github.com/getsentry/sentry-java/pull/6275)) - Send a monitor config derived from Spring `@Scheduled` with `@SentryCheckIn` check-ins by default, so Sentry creates or updates the monitor from code. Set `@SentryCheckIn(upsertMonitorConfig = false)` to turn this off ([#6215](https://github.com/getsentry/sentry-java/pull/6215)) - Report the cellular network technology generation in `device.connection_effective_type`, for example `4g` or `5g` ([#6146](https://github.com/getsentry/sentry-java/pull/6146)) From e20963e0ac83e9da3a00e1c54fb2ca2a6cb660fa Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:59:14 -0700 Subject: [PATCH 14/38] feat(spring): Monitor all @Scheduled methods in Spring Boot 2 Co-Authored-By: Claude --- sentry-spring-boot/api/sentry-spring-boot.api | 4 + .../spring/boot/SentryAutoConfiguration.java | 29 +++ .../sentry/spring/boot/SentryProperties.java | 25 ++ .../boot/SentryAutoConfigurationTest.kt | 58 +++++ sentry-spring/api/sentry-spring.api | 6 + .../spring/checkin/SentryCheckInAdvice.java | 66 +++++ .../SentryScheduledCheckInPointcut.java | 55 ++++ .../checkin/SentryScheduledCheckInTest.kt | 238 ++++++++++++++++++ 8 files changed, 481 insertions(+) create mode 100644 sentry-spring/src/main/java/io/sentry/spring/checkin/SentryScheduledCheckInPointcut.java create mode 100644 sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt diff --git a/sentry-spring-boot/api/sentry-spring-boot.api b/sentry-spring-boot/api/sentry-spring-boot.api index fe13689faf5..4fafe9d695b 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 } 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 a405e007cbd..744fa9d739a 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; @@ -47,11 +49,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; @@ -70,6 +77,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; @@ -292,6 +300,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 879b4e2ada6..cf8c6934c50 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. */ 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 31be74477fd..20215ee145f 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 @@ -20,6 +20,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 @@ -50,6 +51,7 @@ import io.sentry.transport.ITransportGate import io.sentry.transport.apache.ApacheHttpClientTransportFactory 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 @@ -58,9 +60,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 @@ -71,6 +76,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 @@ -85,6 +91,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 @@ -1126,6 +1134,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 @@ -1241,6 +1287,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/api/sentry-spring.api b/sentry-spring/api/sentry-spring.api index 8b8bfa69c70..edd99b8fc1c 100644 --- a/sentry-spring/api/sentry-spring.api +++ b/sentry-spring/api/sentry-spring.api @@ -141,6 +141,7 @@ public abstract interface annotation class io/sentry/spring/checkin/SentryCheckI 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 } @@ -161,6 +162,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/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckInAdvice.java index 202bd30e56a..ca9cfedda03 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 @@ -15,12 +15,16 @@ 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; @@ -48,8 +52,14 @@ public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueReso "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 = @@ -60,7 +70,16 @@ public SentryCheckInAdvice() { } 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 @@ -72,6 +91,14 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl SentryCheckIn checkInAnnotation = AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); if (checkInAnnotation == null) { + if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) { + return captureCheckIns( + invocation, + scheduledTaskSlug( + scheduledTaskName(invocation.getThis().getClass(), mostSpecificMethod)), + false, + monitorConfig(mostSpecificMethod)); + } return invocation.proceed(); } @@ -114,7 +141,15 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl !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); @@ -143,6 +178,37 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } } + static boolean isScheduled(final @NotNull Method method) { + return !AnnotatedElementUtils.findMergedRepeatableAnnotations(method, Scheduled.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))) 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..9d231240b02 --- /dev/null +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryScheduledCheckInPointcut.java @@ -0,0 +1,55 @@ +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.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.Experimental +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 + if (Modifier.isFinal(userClass.getModifiers()) || Modifier.isFinal(method.getModifiers())) { + return false; + } + final @NotNull Method specificMethod = AopUtils.getMostSpecificMethod(method, userClass); + if (!SentryCheckInAdvice.isScheduled(specificMethod) + || AnnotationUtils.findAnnotation(specificMethod, SentryCheckIn.class) != 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/checkin/SentryScheduledCheckInTest.kt b/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt new file mode 100644 index 00000000000..83102dbaf91 --- /dev/null +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt @@ -0,0 +1,238 @@ +package io.sentry.spring.checkin + +import io.sentry.CheckIn +import io.sentry.CheckInStatus +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +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.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.aop.Advisor +import org.springframework.aop.Pointcut +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 + +@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" + + @Autowired lateinit var jobs: Jobs + + @Autowired lateinit var excludedJobs: ExcludedJobs + + @Autowired lateinit var finalJobs: FinalJobs + + @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 `fixed delay is monitored without config`() { + val checkIns = capture { jobs.fixedDelay() } + assertEquals(2, checkIns.size) + 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 }) + } + + @Test + fun `methods without @Scheduled are not monitored`() { + jobs.notScheduled() + 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 `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"), + ), + ) + } + + 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 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() {} + + @Scheduled(cron = "0 0 2 * * *") open fun excluded() {} + + open fun notScheduled() {} + } + + open class ExcludedJobs { + @Scheduled(cron = "0 0 2 * * *") open fun run() {} + } + + class FinalJobs { + @Scheduled(cron = "0 0 2 * * *") fun run() {} + } +} From 7b150aecb0675125e6d75f318d15702bf7517f86 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 17:59:27 -0700 Subject: [PATCH 15/38] docs(changelog): Link Spring Boot 2 PR Co-Authored-By: Claude --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c3ec23dd2..f25c8c7412c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Monitor every Spring `@Scheduled` method with Sentry Crons by setting `sentry.monitor-scheduled-tasks=true`, without adding `@SentryCheckIn` to each method. Exclude methods with `sentry.exclude-scheduled-tasks` ([#6275](https://github.com/getsentry/sentry-java/pull/6275)) +- Monitor every Spring `@Scheduled` method with Sentry Crons by setting `sentry.monitor-scheduled-tasks=true`, without adding `@SentryCheckIn` to each method. Exclude methods with `sentry.exclude-scheduled-tasks` ([#6275](https://github.com/getsentry/sentry-java/pull/6275), [#6276](https://github.com/getsentry/sentry-java/pull/6276)) - Send a monitor config derived from Spring `@Scheduled` with `@SentryCheckIn` check-ins by default, so Sentry creates or updates the monitor from code. Set `@SentryCheckIn(upsertMonitorConfig = false)` to turn this off ([#6215](https://github.com/getsentry/sentry-java/pull/6215)) - Report the cellular network technology generation in `device.connection_effective_type`, for example `4g` or `5g` ([#6146](https://github.com/getsentry/sentry-java/pull/6146)) From fcd00c7f6fff03d93aa23273c57a3b3d00a6f833 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 18:01:08 -0700 Subject: [PATCH 16/38] feat(spring): Monitor all @Scheduled methods in Spring Boot 4 Co-Authored-By: Claude --- sentry-spring-7/api/sentry-spring-7.api | 6 + .../spring7/checkin/SentryCheckInAdvice.java | 66 +++++ .../SentryScheduledCheckInPointcut.java | 55 ++++ .../checkin/SentryScheduledCheckInTest.kt | 238 ++++++++++++++++++ .../api/sentry-spring-boot-4.api | 4 + .../spring/boot4/SentryAutoConfiguration.java | 29 +++ .../sentry/spring/boot4/SentryProperties.java | 25 ++ .../boot4/SentryAutoConfigurationTest.kt | 58 +++++ 8 files changed, 481 insertions(+) create mode 100644 sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryScheduledCheckInPointcut.java create mode 100644 sentry-spring-7/src/test/kotlin/io/sentry/spring7/checkin/SentryScheduledCheckInTest.kt diff --git a/sentry-spring-7/api/sentry-spring-7.api b/sentry-spring-7/api/sentry-spring-7.api index c5aaf0a5d1b..62fb5d527a9 100644 --- a/sentry-spring-7/api/sentry-spring-7.api +++ b/sentry-spring-7/api/sentry-spring-7.api @@ -143,6 +143,7 @@ public abstract interface annotation class io/sentry/spring7/checkin/SentryCheck public class io/sentry/spring7/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 } @@ -163,6 +164,11 @@ public class io/sentry/spring7/checkin/SentryQuartzConfiguration { public fun schedulerFactoryBeanCustomizer ()Lorg/springframework/boot/quartz/autoconfigure/SchedulerFactoryBeanCustomizer; } +public final class io/sentry/spring7/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/spring7/checkin/SentrySchedulerFactoryBeanCustomizer : org/springframework/boot/quartz/autoconfigure/SchedulerFactoryBeanCustomizer { public fun ()V public fun customize (Lorg/springframework/scheduling/quartz/SchedulerFactoryBean;)V 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 3051acbb607..7a847d63749 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 @@ -15,12 +15,16 @@ 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; @@ -48,8 +52,14 @@ public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueReso "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 = @@ -60,7 +70,16 @@ public SentryCheckInAdvice() { } 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 @@ -72,6 +91,14 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl SentryCheckIn checkInAnnotation = AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); if (checkInAnnotation == null) { + if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) { + return captureCheckIns( + invocation, + scheduledTaskSlug( + scheduledTaskName(invocation.getThis().getClass(), mostSpecificMethod)), + false, + monitorConfig(mostSpecificMethod)); + } return invocation.proceed(); } @@ -111,7 +138,15 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl !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); @@ -140,6 +175,37 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl } } + static boolean isScheduled(final @NotNull Method method) { + return !AnnotatedElementUtils.findMergedRepeatableAnnotations(method, Scheduled.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))) 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..de39ad4d608 --- /dev/null +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/checkin/SentryScheduledCheckInPointcut.java @@ -0,0 +1,55 @@ +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.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.Experimental +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 + if (Modifier.isFinal(userClass.getModifiers()) || Modifier.isFinal(method.getModifiers())) { + return false; + } + final @NotNull Method specificMethod = AopUtils.getMostSpecificMethod(method, userClass); + if (!SentryCheckInAdvice.isScheduled(specificMethod) + || AnnotationUtils.findAnnotation(specificMethod, SentryCheckIn.class) != 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/checkin/SentryScheduledCheckInTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/checkin/SentryScheduledCheckInTest.kt new file mode 100644 index 00000000000..2435455ee77 --- /dev/null +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/checkin/SentryScheduledCheckInTest.kt @@ -0,0 +1,238 @@ +package io.sentry.spring7.checkin + +import io.sentry.CheckIn +import io.sentry.CheckInStatus +import io.sentry.IScopes +import io.sentry.ISentryLifecycleToken +import io.sentry.SentryOptions +import io.sentry.protocol.SentryId +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.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.reset +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.aop.Advisor +import org.springframework.aop.Pointcut +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 + +@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" + + @Autowired lateinit var jobs: Jobs + + @Autowired lateinit var excludedJobs: ExcludedJobs + + @Autowired lateinit var finalJobs: FinalJobs + + @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 `fixed delay is monitored without config`() { + val checkIns = capture { jobs.fixedDelay() } + assertEquals(2, checkIns.size) + 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 }) + } + + @Test + fun `methods without @Scheduled are not monitored`() { + jobs.notScheduled() + 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 `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"), + ), + ) + } + + 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 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() {} + + @Scheduled(cron = "0 0 2 * * *") open fun excluded() {} + + open fun notScheduled() {} + } + + open class ExcludedJobs { + @Scheduled(cron = "0 0 2 * * *") open fun run() {} + } + + class FinalJobs { + @Scheduled(cron = "0 0 2 * * *") fun run() {} + } +} 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 f75c731c8b1..080bb6d6f6a 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 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 0b6184930fe..8562d14f160 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; @@ -49,11 +51,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; @@ -73,6 +80,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; @@ -307,6 +315,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 8773b4b64e3..b99450644fc 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. */ 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 c0eb866de02..6cd61dae0de 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 @@ -18,6 +18,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 @@ -48,6 +49,7 @@ import io.sentry.transport.ITransportGate import io.sentry.transport.apache.ApacheHttpClientTransportFactory 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 @@ -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.context.annotation.UserConfigurations import org.springframework.boot.info.GitProperties import org.springframework.boot.quartz.autoconfigure.QuartzAutoConfiguration @@ -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 @@ -1070,6 +1078,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 @@ -1316,6 +1362,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 { From b90f3641416aa9d3c3efcccd7877fd67b5586b30 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 16 Oct 2026 18:01:16 -0700 Subject: [PATCH 17/38] docs(changelog): Link Spring Boot 4 PR Co-Authored-By: Claude --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f25c8c7412c..df67d167d7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Monitor every Spring `@Scheduled` method with Sentry Crons by setting `sentry.monitor-scheduled-tasks=true`, without adding `@SentryCheckIn` to each method. Exclude methods with `sentry.exclude-scheduled-tasks` ([#6275](https://github.com/getsentry/sentry-java/pull/6275), [#6276](https://github.com/getsentry/sentry-java/pull/6276)) +- Monitor every Spring `@Scheduled` method with Sentry Crons by setting `sentry.monitor-scheduled-tasks=true`, without adding `@SentryCheckIn` to each method. Exclude methods with `sentry.exclude-scheduled-tasks` ([#6275](https://github.com/getsentry/sentry-java/pull/6275), [#6276](https://github.com/getsentry/sentry-java/pull/6276), [#6277](https://github.com/getsentry/sentry-java/pull/6277)) - Send a monitor config derived from Spring `@Scheduled` with `@SentryCheckIn` check-ins by default, so Sentry creates or updates the monitor from code. Set `@SentryCheckIn(upsertMonitorConfig = false)` to turn this off ([#6215](https://github.com/getsentry/sentry-java/pull/6215)) - Report the cellular network technology generation in `device.connection_effective_type`, for example `4g` or `5g` ([#6146](https://github.com/getsentry/sentry-java/pull/6146)) From 552de8f13f01c688a5fa2b6db252fcce4925c61d Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 20 Oct 2026 10:46:35 -0700 Subject: [PATCH 18/38] fix(spring-jakarta): Skip reactive @Scheduled methods when monitoring all tasks Spring calls reactive and suspend @Scheduled methods once at startup and subscribes to the result on each run, so check-ins around the call would only be sent once. Co-Authored-By: Claude --- .../spring/jakarta/checkin/SentryCheckInAdvice.java | 7 ++----- .../jakarta/checkin/SentryScheduledCheckInPointcut.java | 9 ++++++++- .../spring/jakarta/checkin/SentryScheduledCheckInTest.kt | 9 +++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) 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 535ec1ad22f..a9a936e240b 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 @@ -241,10 +241,7 @@ static boolean isScheduled(final @NotNull Method method) { scopes .getOptions() .getLogger() - .log( - SentryLevel.WARNING, - "Could not derive a monitor config from @Scheduled for method annotated with @SentryCheckIn.", - e); + .log(SentryLevel.WARNING, "Could not derive a monitor config from @Scheduled.", e); return null; } } @@ -275,7 +272,7 @@ static boolean isScheduled(final @NotNull Method method) { .getLogger() .log( SentryLevel.DEBUG, - "Not sending a monitor config for @SentryCheckIn because the @Scheduled period '%s' could not be parsed.", + "Not sending a monitor config because the @Scheduled period '%s' could not be parsed.", trimmed); } return millis; 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 index b7d31f93a45..b0af348a275 100644 --- 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 @@ -10,6 +10,7 @@ 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; @@ -17,7 +18,7 @@ * Matches {@code @Scheduled} methods without {@link SentryCheckIn}, except excluded ones. Excludes * match the task name ({@code ClassName.methodName}) exactly or as a regex. */ -@ApiStatus.Experimental +@ApiStatus.Internal public final class SentryScheduledCheckInPointcut extends StaticMethodMatcherPointcut { private final @NotNull List excludedScheduledTasks = new ArrayList<>(); @@ -43,6 +44,12 @@ public boolean matches(final @NotNull Method method, final @NotNull Class tar || 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) { 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 index ee180f54b20..5ff8fb8d627 100644 --- 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 @@ -38,6 +38,7 @@ 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) @@ -106,6 +107,12 @@ class SentryScheduledCheckInTest { 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() @@ -226,6 +233,8 @@ class SentryScheduledCheckInTest { @Scheduled(cron = "0 0 2 * * *") open fun excluded() {} open fun notScheduled() {} + + @Scheduled(cron = "0 0 2 * * *") open fun reactive(): Mono = Mono.empty() } open class ExcludedJobs { From 6261137f110882214943fb3bc90634ed89090a1b Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 20 Oct 2026 10:48:10 -0700 Subject: [PATCH 19/38] fix(spring): Skip reactive @Scheduled methods when monitoring all tasks Same fix as the Spring Boot 3 copy. Co-Authored-By: Claude --- .../io/sentry/spring/checkin/SentryCheckInAdvice.java | 7 ++----- .../spring/checkin/SentryScheduledCheckInPointcut.java | 9 ++++++++- .../sentry/spring/checkin/SentryScheduledCheckInTest.kt | 9 +++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) 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 ca9cfedda03..06490ef0e7e 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 @@ -244,10 +244,7 @@ static boolean isScheduled(final @NotNull Method method) { scopes .getOptions() .getLogger() - .log( - SentryLevel.WARNING, - "Could not derive a monitor config from @Scheduled for method annotated with @SentryCheckIn.", - e); + .log(SentryLevel.WARNING, "Could not derive a monitor config from @Scheduled.", e); return null; } } @@ -278,7 +275,7 @@ static boolean isScheduled(final @NotNull Method method) { .getLogger() .log( SentryLevel.DEBUG, - "Not sending a monitor config for @SentryCheckIn because the @Scheduled period '%s' could not be parsed.", + "Not sending a monitor config because the @Scheduled period '%s' could not be parsed.", trimmed); } return millis; 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 index 9d231240b02..ef3edbd2225 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryScheduledCheckInPointcut.java +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryScheduledCheckInPointcut.java @@ -10,6 +10,7 @@ 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; @@ -17,7 +18,7 @@ * Matches {@code @Scheduled} methods without {@link SentryCheckIn}, except excluded ones. Excludes * match the task name ({@code ClassName.methodName}) exactly or as a regex. */ -@ApiStatus.Experimental +@ApiStatus.Internal public final class SentryScheduledCheckInPointcut extends StaticMethodMatcherPointcut { private final @NotNull List excludedScheduledTasks = new ArrayList<>(); @@ -43,6 +44,12 @@ public boolean matches(final @NotNull Method method, final @NotNull Class tar || 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) { 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 index 83102dbaf91..34dd98e73bd 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt @@ -38,6 +38,7 @@ 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) @@ -106,6 +107,12 @@ class SentryScheduledCheckInTest { 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() @@ -226,6 +233,8 @@ class SentryScheduledCheckInTest { @Scheduled(cron = "0 0 2 * * *") open fun excluded() {} open fun notScheduled() {} + + @Scheduled(cron = "0 0 2 * * *") open fun reactive(): Mono = Mono.empty() } open class ExcludedJobs { From aa3995dc4562dd3d8f5d435bb628bb43034c0f3f Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Tue, 20 Oct 2026 10:49:39 -0700 Subject: [PATCH 20/38] fix(spring-7): Skip reactive @Scheduled methods when monitoring all tasks Same fix as the Spring Boot 3 copy. Co-Authored-By: Claude --- .../io/sentry/spring7/checkin/SentryCheckInAdvice.java | 7 ++----- .../spring7/checkin/SentryScheduledCheckInPointcut.java | 9 ++++++++- .../sentry/spring7/checkin/SentryScheduledCheckInTest.kt | 9 +++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) 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 7a847d63749..f5983f31a24 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 @@ -241,10 +241,7 @@ static boolean isScheduled(final @NotNull Method method) { scopes .getOptions() .getLogger() - .log( - SentryLevel.WARNING, - "Could not derive a monitor config from @Scheduled for method annotated with @SentryCheckIn.", - e); + .log(SentryLevel.WARNING, "Could not derive a monitor config from @Scheduled.", e); return null; } } @@ -275,7 +272,7 @@ static boolean isScheduled(final @NotNull Method method) { .getLogger() .log( SentryLevel.DEBUG, - "Not sending a monitor config for @SentryCheckIn because the @Scheduled period '%s' could not be parsed.", + "Not sending a monitor config because the @Scheduled period '%s' could not be parsed.", trimmed); } return millis; 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 index de39ad4d608..229d3366720 100644 --- 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 @@ -10,6 +10,7 @@ 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; @@ -17,7 +18,7 @@ * Matches {@code @Scheduled} methods without {@link SentryCheckIn}, except excluded ones. Excludes * match the task name ({@code ClassName.methodName}) exactly or as a regex. */ -@ApiStatus.Experimental +@ApiStatus.Internal public final class SentryScheduledCheckInPointcut extends StaticMethodMatcherPointcut { private final @NotNull List excludedScheduledTasks = new ArrayList<>(); @@ -43,6 +44,12 @@ public boolean matches(final @NotNull Method method, final @NotNull Class tar || 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) { 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 index 2435455ee77..fd0d9082e5c 100644 --- 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 @@ -38,6 +38,7 @@ 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) @@ -106,6 +107,12 @@ class SentryScheduledCheckInTest { 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() @@ -226,6 +233,8 @@ class SentryScheduledCheckInTest { @Scheduled(cron = "0 0 2 * * *") open fun excluded() {} open fun notScheduled() {} + + @Scheduled(cron = "0 0 2 * * *") open fun reactive(): Mono = Mono.empty() } open class ExcludedJobs { From 26054cda05b7c2d140be77f4451ca3ad00c7c24b Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 11:01:10 -0700 Subject: [PATCH 21/38] fix(spring): Skip jobs with unsupported schedules when monitoring all jobs --- .../jakarta/checkin/SentryCheckInAdvice.java | 20 +++++++++++++------ .../checkin/SentryScheduledCheckInTest.kt | 14 ++++++++++--- 2 files changed, 25 insertions(+), 9 deletions(-) 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 a9a936e240b..37f1b39e1b5 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 @@ -92,12 +92,20 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); if (checkInAnnotation == null) { if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) { - return captureCheckIns( - invocation, - scheduledTaskSlug( - scheduledTaskName(invocation.getThis().getClass(), mostSpecificMethod)), - false, - monitorConfig(mostSpecificMethod)); + final @NotNull String taskName = + scheduledTaskName(invocation.getThis().getClass(), 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(); + } + return captureCheckIns(invocation, scheduledTaskSlug(taskName), false, monitorConfig); } return invocation.proceed(); } 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 index 5ff8fb8d627..9d36cbb5005 100644 --- 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 @@ -83,9 +83,15 @@ class SentryScheduledCheckInTest { } @Test - fun `fixed delay is monitored without config`() { - val checkIns = capture { jobs.fixedDelay() } - assertEquals(2, checkIns.size) + 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) } @@ -230,6 +236,8 @@ class SentryScheduledCheckInTest { @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() {} From 7baac070c3e936ea34f1e0704f3191b8c9c0aadd Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 11:01:25 -0700 Subject: [PATCH 22/38] fix(spring): Skip jobs with unsupported schedules when monitoring all jobs --- .../spring/checkin/SentryCheckInAdvice.java | 20 +++++++++++++------ .../checkin/SentryScheduledCheckInTest.kt | 14 ++++++++++--- 2 files changed, 25 insertions(+), 9 deletions(-) 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 06490ef0e7e..d89f69a364d 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 @@ -92,12 +92,20 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); if (checkInAnnotation == null) { if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) { - return captureCheckIns( - invocation, - scheduledTaskSlug( - scheduledTaskName(invocation.getThis().getClass(), mostSpecificMethod)), - false, - monitorConfig(mostSpecificMethod)); + final @NotNull String taskName = + scheduledTaskName(invocation.getThis().getClass(), 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(); + } + return captureCheckIns(invocation, scheduledTaskSlug(taskName), false, monitorConfig); } return invocation.proceed(); } 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 index 34dd98e73bd..5a0ed4c986f 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt @@ -83,9 +83,15 @@ class SentryScheduledCheckInTest { } @Test - fun `fixed delay is monitored without config`() { - val checkIns = capture { jobs.fixedDelay() } - assertEquals(2, checkIns.size) + 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) } @@ -230,6 +236,8 @@ class SentryScheduledCheckInTest { @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() {} From 99505ac1e09722fded6bf4f1f6746204d8a3345e Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 11:01:33 -0700 Subject: [PATCH 23/38] fix(spring): Skip jobs with unsupported schedules when monitoring all jobs --- .../spring7/checkin/SentryCheckInAdvice.java | 20 +++++++++++++------ .../checkin/SentryScheduledCheckInTest.kt | 14 ++++++++++--- 2 files changed, 25 insertions(+), 9 deletions(-) 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 f5983f31a24..cc1832977f9 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 @@ -92,12 +92,20 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); if (checkInAnnotation == null) { if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) { - return captureCheckIns( - invocation, - scheduledTaskSlug( - scheduledTaskName(invocation.getThis().getClass(), mostSpecificMethod)), - false, - monitorConfig(mostSpecificMethod)); + final @NotNull String taskName = + scheduledTaskName(invocation.getThis().getClass(), 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(); + } + return captureCheckIns(invocation, scheduledTaskSlug(taskName), false, monitorConfig); } return invocation.proceed(); } 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 index fd0d9082e5c..7db4c0ccaa5 100644 --- 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 @@ -83,9 +83,15 @@ class SentryScheduledCheckInTest { } @Test - fun `fixed delay is monitored without config`() { - val checkIns = capture { jobs.fixedDelay() } - assertEquals(2, checkIns.size) + 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) } @@ -230,6 +236,8 @@ class SentryScheduledCheckInTest { @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() {} From 1e7aa247acd399c733e889d190b07208c1d5d420 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 11:54:43 -0700 Subject: [PATCH 24/38] ref(spring): Use pattern-matching instanceof for the @Scheduled time unit --- .../java/io/sentry/spring7/checkin/SentryCheckInAdvice.java | 4 +--- .../io/sentry/spring/jakarta/checkin/SentryCheckInAdvice.java | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) 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 6bcb823032f..651079fc812 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 @@ -160,9 +160,7 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) { final @Nullable Object timeUnitAttribute = AnnotationUtils.getAnnotationAttributes(scheduled).get("timeUnit"); final @NotNull TimeUnit timeUnit = - timeUnitAttribute instanceof TimeUnit - ? (TimeUnit) timeUnitAttribute - : TimeUnit.MILLISECONDS; + 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 = 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 1260e9ba05a..f0394964fa1 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 @@ -158,9 +158,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl final @Nullable Object timeUnitAttribute = AnnotationUtils.getAnnotationAttributes(scheduled).get("timeUnit"); final @NotNull TimeUnit timeUnit = - timeUnitAttribute instanceof TimeUnit - ? (TimeUnit) timeUnitAttribute - : TimeUnit.MILLISECONDS; + 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 = From 5b38d5ecc2dd6291d066ddc39244106b06bf1bcb Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 29 Oct 2026 12:15:04 -0700 Subject: [PATCH 25/38] fix(spring): Satisfy NullAway in the Spring 7 scheduled check-in advice --- .../java/io/sentry/spring7/checkin/SentryCheckInAdvice.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 90b6e71e301..8c5b4117f38 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 @@ -95,7 +95,9 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes, final boolean monitorS if (checkInAnnotation == null) { if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) { final @NotNull String taskName = - scheduledTaskName(invocation.getThis().getClass(), mostSpecificMethod); + scheduledTaskName( + target != null ? target.getClass() : mostSpecificMethod.getDeclaringClass(), + mostSpecificMethod); final @Nullable MonitorConfig monitorConfig = monitorConfig(mostSpecificMethod); if (monitorConfig == null) { scopes @@ -151,7 +153,7 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes, final boolean monitorS return captureCheckIns(invocation, monitorSlug, isHeartbeatOnly, monitorConfig); } - private Object captureCheckIns( + private @Nullable Object captureCheckIns( final @NotNull MethodInvocation invocation, final @NotNull String monitorSlug, final boolean isHeartbeatOnly, From 0b9b8527584295d78798b7c07018b12cb6dde4d0 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 11:31:51 -0500 Subject: [PATCH 26/38] fix(core): Use a 2 minute check-in margin for crons with fixed seconds Dropping a non-zero seconds field makes check-ins arrive up to 59s after the expected minute, which a 1 minute margin barely covers. --- .../io/sentry/util/MonitorConfigUtils.java | 13 +++++ .../io/sentry/util/MonitorConfigUtilsTest.kt | 50 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java index 1e033d335e3..4058529b12f 100644 --- a/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java +++ b/sentry/src/main/java/io/sentry/util/MonitorConfigUtils.java @@ -25,6 +25,7 @@ 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})$"); @@ -91,6 +92,13 @@ private MonitorConfigUtils() {} return null; } final @NotNull MonitorConfig config = new MonitorConfig(MonitorSchedule.crontab(crontab)); + // Runs check in up to 59s after the minute Sentry expects, so a 1 minute margin is too tight + if (hasNonZeroSeconds(cron)) { + final @Nullable Long margin = config.getCheckinMargin(); + if (margin == null || margin < MIN_CHECKIN_MARGIN_WITH_SECONDS) { + config.setCheckinMargin(MIN_CHECKIN_MARGIN_WITH_SECONDS); + } + } if (zone != null && !zone.isEmpty()) { final @Nullable String timezone = toTimezone(zone); if (timezone == null) { @@ -123,6 +131,11 @@ private MonitorConfigUtils() {} return null; } + private static boolean hasNonZeroSeconds(final @NotNull String cron) { + final @NotNull String seconds = cron.trim().split("\\s+", -1)[0]; + return DIGITS.matcher(seconds).matches() && Integer.parseInt(seconds) > 0; + } + /** * Builds a monitor config from Spring {@code @Scheduled} values with placeholders resolved. Reads * the cron and a cron without zone the way Spring does. diff --git a/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt index 69b8f8833ed..e882b3ed070 100644 --- a/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/MonitorConfigUtilsTest.kt @@ -1,9 +1,15 @@ package io.sentry.util import com.google.common.truth.Truth.assertThat +import io.sentry.IScopes +import io.sentry.Sentry +import io.sentry.SentryOptions import java.util.TimeZone import java.util.concurrent.TimeUnit import kotlin.test.Test +import org.mockito.Mockito +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever class MonitorConfigUtilsTest { @@ -18,6 +24,50 @@ class MonitorConfigUtilsTest { assertThat(config.timezone).isNull() } + @Test + fun `cron at second 0 keeps the default check-in margin`() { + val config = MonitorConfigUtils.fromSchedule("0 * * * * *", null, null) + + assertThat(config!!.checkinMargin).isNull() + } + + @Test + fun `cron with non-zero seconds gets a 2 minute check-in margin`() { + for (cron in listOf("1 * * * * *", "59 * * * * *", "30 15 10 * * *")) { + assertThat(MonitorConfigUtils.fromSchedule(cron, null, null)!!.checkinMargin).isEqualTo(2L) + } + } + + @Test + fun `cron with non-zero seconds raises a smaller default check-in margin`() { + withDefaultCheckinMargin(1L) { + assertThat(MonitorConfigUtils.fromSchedule("59 * * * * *", null, null)!!.checkinMargin) + .isEqualTo(2L) + } + } + + @Test + fun `cron with non-zero seconds keeps a larger default check-in margin`() { + withDefaultCheckinMargin(5L) { + assertThat(MonitorConfigUtils.fromSchedule("59 * * * * *", null, null)!!.checkinMargin) + .isEqualTo(5L) + } + } + + private fun withDefaultCheckinMargin(margin: Long, block: () -> Unit) { + Mockito.mockStatic(Sentry::class.java).use { sentry -> + val scopes = mock() + sentry.`when` { Sentry.getCurrentScopes() }.thenReturn(scopes) + whenever(scopes.options) + .thenReturn( + SentryOptions().apply { + cron = SentryOptions.Cron().apply { defaultCheckinMargin = margin } + } + ) + block() + } + } + @Test fun `cron whitespace is normalized`() { val config = MonitorConfigUtils.fromSchedule(" 30 0 2 * * * ", null, null) From 129cb735b56551cc5df5ed028642ab9feeaa947c Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 11:37:19 -0500 Subject: [PATCH 27/38] fix(spring): Default upsertMonitorConfig to false and read @Scheduled like Spring Existing @SentryCheckIn users shouldn't have their monitors changed on upgrade, so sending the schedule is opt-in. Find @Scheduled with Spring's own lookup so an overriding method doesn't inherit a parent's schedule. --- CHANGELOG.md | 2 +- .../sentry/spring7/checkin/SentryCheckIn.java | 7 +-- .../spring7/checkin/SentryCheckInAdvice.java | 4 +- .../sentry/spring7/SentryCheckInAdviceTest.kt | 62 ++++++++++++++----- .../spring/jakarta/checkin/SentryCheckIn.java | 7 +-- .../jakarta/checkin/SentryCheckInAdvice.java | 4 +- .../spring/jakarta/SentryCheckInAdviceTest.kt | 62 ++++++++++++++----- .../sentry/spring/checkin/SentryCheckIn.java | 7 +-- .../spring/checkin/SentryCheckInAdvice.java | 4 +- .../sentry/spring/SentryCheckInAdviceTest.kt | 62 ++++++++++++++----- 10 files changed, 157 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2434542f46..50cb3134dfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- Send a monitor config derived from Spring `@Scheduled` with `@SentryCheckIn` check-ins by default, so Sentry creates or updates the monitor from code. Set `@SentryCheckIn(upsertMonitorConfig = false)` to turn this off ([#6215](https://github.com/getsentry/sentry-java/pull/6215)) +- Send a monitor config derived from Spring `@Scheduled` with `@SentryCheckIn` check-ins, so Sentry creates or updates the monitor from code. Set `@SentryCheckIn(upsertMonitorConfig = true)` to send the schedule ([#6215](https://github.com/getsentry/sentry-java/pull/6215)) - Added `Environment` to the `Scope`, allowing you to overwrite the `Environment` set on the options ([#5772](https://github.com/getsentry/sentry-java/pull/5772)) - The scope `Environment` is also applied to the trace envelope header (DSC), logs, metrics and profiles, so all telemetry of a trace reports the same environment - Add an `attachTracingHeaders` flag to `SentryApolloInterceptor` to stop attaching `sentry-trace` and `baggage` headers to Apollo v2 requests ([#6414](https://github.com/getsentry/sentry-java/pull/6414)) 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 92bd8046ae7..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 @@ -31,14 +31,13 @@ /** * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so - * Sentry creates or updates the monitor. Set to false to manage the monitor's schedule in Sentry - * instead. + * Sentry creates or updates the monitor. * *

Heartbeat check-ins never send a monitor config. * - * @return true to send a monitor config, true by default + * @return true to send a monitor config, false by default */ - boolean upsertMonitorConfig() default true; + 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 651079fc812..ca0137d7714 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 @@ -31,6 +31,7 @@ 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; @@ -151,7 +152,8 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes) { private @Nullable MonitorConfig monitorConfigFromScheduled(final @NotNull Method method) { try { final @NotNull Set schedules = - AnnotatedElementUtils.findMergedRepeatableAnnotations(method, Scheduled.class); + AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class); if (schedules.size() != 1) { return null; } 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 a071ba293ca..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 @@ -67,6 +67,8 @@ class SentryCheckInAdviceTest { @Autowired lateinit var sampleServiceScheduled: SampleServiceScheduled + @Autowired lateinit var sampleServiceInheritedSchedule: SampleServiceInheritedSchedule + @Autowired lateinit var scopes: IScopes val lifecycleToken = mock() @@ -370,6 +372,16 @@ class SentryCheckInAdviceTest { 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() @@ -405,6 +417,8 @@ class SentryCheckInAdviceTest { @Bean open fun sampleServiceScheduled() = SampleServiceScheduled() + @Bean open fun sampleServiceInheritedSchedule() = SampleServiceInheritedSchedule() + @Bean open fun scopes(): IScopes { val scopes = mock() @@ -457,57 +471,63 @@ class SentryCheckInAdviceTest { open class SampleServiceScheduled { - @SentryCheckIn("cron_zone") + @SentryCheckIn("cron_zone", upsertMonitorConfig = true) @Scheduled(cron = "0 15 10 * * MON-FRI", zone = "Europe/Vienna") open fun cronWithZone() {} - @SentryCheckIn("cron") @Scheduled(cron = "0 0 2 * * *") open fun cron() {} + @SentryCheckIn("cron", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 2 * * *") + open fun cron() {} - @SentryCheckIn("cron_properties") + @SentryCheckIn("cron_properties", upsertMonitorConfig = true) @Scheduled(cron = "\${my.cron.schedule}", zone = "\${my.cron.zone}") open fun cronFromProperties() {} - @SentryCheckIn("fixed_rate_hours") + @SentryCheckIn("fixed_rate_hours", upsertMonitorConfig = true) @Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS) open fun fixedRateHours() {} - @SentryCheckIn("fixed_rate_iso") + @SentryCheckIn("fixed_rate_iso", upsertMonitorConfig = true) @Scheduled(fixedRateString = "PT10M") open fun fixedRateIso() {} - @SentryCheckIn("fixed_rate_simple") + @SentryCheckIn("fixed_rate_simple", upsertMonitorConfig = true) @Scheduled(fixedRateString = "5m") open fun fixedRateSimpleDuration() {} - @SentryCheckIn("fixed_delay") @Scheduled(fixedDelay = 300_000) open fun fixedDelay() {} + @SentryCheckIn("fixed_delay", upsertMonitorConfig = true) + @Scheduled(fixedDelay = 300_000) + open fun fixedDelay() {} - @SentryCheckIn("fixed_delay_iso") + @SentryCheckIn("fixed_delay_iso", upsertMonitorConfig = true) @Scheduled(fixedDelayString = "PT10M") open fun fixedDelayIso() {} - @SentryCheckIn("disabled_cron") @Scheduled(cron = "-") open fun disabledCron() {} + @SentryCheckIn("disabled_cron", upsertMonitorConfig = true) + @Scheduled(cron = "-") + open fun disabledCron() {} - @SentryCheckIn("cron_empty_zone") + @SentryCheckIn("cron_empty_zone", upsertMonitorConfig = true) @Scheduled(cron = "0 0 3 * * *", zone = "\${my.cron.empty.zone}") open fun cronWithEmptyZone() {} - @SentryCheckIn("cron_zone_defaults") + @SentryCheckIn("cron_zone_defaults", upsertMonitorConfig = true) @Scheduled(cron = "0 0 4 * * *", zone = "Europe/Vienna") open fun cronWithZoneAndDefaults() {} - @SentryCheckIn("schedules_container") + @SentryCheckIn("schedules_container", upsertMonitorConfig = true) @Schedules(Scheduled(cron = "0 0 1 * * *"), Scheduled(cron = "0 0 13 * * *")) open fun schedulesContainer() {} - @SentryCheckIn("unresolvable_cron") + @SentryCheckIn("unresolvable_cron", upsertMonitorConfig = true) @Scheduled(cron = "\${my.cron.missing}") open fun unresolvableCron() {} - @SentryCheckIn("derivation_throws") + @SentryCheckIn("derivation_throws", upsertMonitorConfig = true) @Scheduled(cron = "\${my.cron.exception.property}") open fun derivationThrows() = 1 - @SentryCheckIn("multiple") + @SentryCheckIn("multiple", upsertMonitorConfig = true) @Scheduled(cron = "0 0 1 * * *") @Scheduled(cron = "0 0 13 * * *") open fun multipleSchedules() {} @@ -516,11 +536,21 @@ class SentryCheckInAdviceTest { @Scheduled(cron = "0 0 1 * * *") open fun upsertDisabled() {} - @SentryCheckIn("heartbeat", heartbeat = true) + @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/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java b/sentry-spring-jakarta/src/main/java/io/sentry/spring/jakarta/checkin/SentryCheckIn.java index 6eb1786bae8..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 @@ -31,14 +31,13 @@ /** * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so - * Sentry creates or updates the monitor. Set to false to manage the monitor's schedule in Sentry - * instead. + * Sentry creates or updates the monitor. * *

Heartbeat check-ins never send a monitor config. * - * @return true to send a monitor config, true by default + * @return true to send a monitor config, false by default */ - boolean upsertMonitorConfig() default true; + 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 f0394964fa1..76b03a917bc 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 @@ -31,6 +31,7 @@ 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; @@ -149,7 +150,8 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl private @Nullable MonitorConfig monitorConfigFromScheduled(final @NotNull Method method) { try { final @NotNull Set schedules = - AnnotatedElementUtils.findMergedRepeatableAnnotations(method, Scheduled.class); + AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class); if (schedules.size() != 1) { return null; } 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 7a7e5b81d04..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 @@ -67,6 +67,8 @@ class SentryCheckInAdviceTest { @Autowired lateinit var sampleServiceScheduled: SampleServiceScheduled + @Autowired lateinit var sampleServiceInheritedSchedule: SampleServiceInheritedSchedule + @Autowired lateinit var scopes: IScopes val lifecycleToken = mock() @@ -370,6 +372,16 @@ class SentryCheckInAdviceTest { 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() @@ -405,6 +417,8 @@ class SentryCheckInAdviceTest { @Bean open fun sampleServiceScheduled() = SampleServiceScheduled() + @Bean open fun sampleServiceInheritedSchedule() = SampleServiceInheritedSchedule() + @Bean open fun scopes(): IScopes { val scopes = mock() @@ -457,57 +471,63 @@ class SentryCheckInAdviceTest { open class SampleServiceScheduled { - @SentryCheckIn("cron_zone") + @SentryCheckIn("cron_zone", upsertMonitorConfig = true) @Scheduled(cron = "0 15 10 * * MON-FRI", zone = "Europe/Vienna") open fun cronWithZone() {} - @SentryCheckIn("cron") @Scheduled(cron = "0 0 2 * * *") open fun cron() {} + @SentryCheckIn("cron", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 2 * * *") + open fun cron() {} - @SentryCheckIn("cron_properties") + @SentryCheckIn("cron_properties", upsertMonitorConfig = true) @Scheduled(cron = "\${my.cron.schedule}", zone = "\${my.cron.zone}") open fun cronFromProperties() {} - @SentryCheckIn("fixed_rate_hours") + @SentryCheckIn("fixed_rate_hours", upsertMonitorConfig = true) @Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS) open fun fixedRateHours() {} - @SentryCheckIn("fixed_rate_iso") + @SentryCheckIn("fixed_rate_iso", upsertMonitorConfig = true) @Scheduled(fixedRateString = "PT10M") open fun fixedRateIso() {} - @SentryCheckIn("fixed_rate_simple") + @SentryCheckIn("fixed_rate_simple", upsertMonitorConfig = true) @Scheduled(fixedRateString = "5m") open fun fixedRateSimpleDuration() {} - @SentryCheckIn("fixed_delay") @Scheduled(fixedDelay = 300_000) open fun fixedDelay() {} + @SentryCheckIn("fixed_delay", upsertMonitorConfig = true) + @Scheduled(fixedDelay = 300_000) + open fun fixedDelay() {} - @SentryCheckIn("fixed_delay_iso") + @SentryCheckIn("fixed_delay_iso", upsertMonitorConfig = true) @Scheduled(fixedDelayString = "PT10M") open fun fixedDelayIso() {} - @SentryCheckIn("disabled_cron") @Scheduled(cron = "-") open fun disabledCron() {} + @SentryCheckIn("disabled_cron", upsertMonitorConfig = true) + @Scheduled(cron = "-") + open fun disabledCron() {} - @SentryCheckIn("cron_empty_zone") + @SentryCheckIn("cron_empty_zone", upsertMonitorConfig = true) @Scheduled(cron = "0 0 3 * * *", zone = "\${my.cron.empty.zone}") open fun cronWithEmptyZone() {} - @SentryCheckIn("cron_zone_defaults") + @SentryCheckIn("cron_zone_defaults", upsertMonitorConfig = true) @Scheduled(cron = "0 0 4 * * *", zone = "Europe/Vienna") open fun cronWithZoneAndDefaults() {} - @SentryCheckIn("schedules_container") + @SentryCheckIn("schedules_container", upsertMonitorConfig = true) @Schedules(Scheduled(cron = "0 0 1 * * *"), Scheduled(cron = "0 0 13 * * *")) open fun schedulesContainer() {} - @SentryCheckIn("unresolvable_cron") + @SentryCheckIn("unresolvable_cron", upsertMonitorConfig = true) @Scheduled(cron = "\${my.cron.missing}") open fun unresolvableCron() {} - @SentryCheckIn("derivation_throws") + @SentryCheckIn("derivation_throws", upsertMonitorConfig = true) @Scheduled(cron = "\${my.cron.exception.property}") open fun derivationThrows() = 1 - @SentryCheckIn("multiple") + @SentryCheckIn("multiple", upsertMonitorConfig = true) @Scheduled(cron = "0 0 1 * * *") @Scheduled(cron = "0 0 13 * * *") open fun multipleSchedules() {} @@ -516,11 +536,21 @@ class SentryCheckInAdviceTest { @Scheduled(cron = "0 0 1 * * *") open fun upsertDisabled() {} - @SentryCheckIn("heartbeat", heartbeat = true) + @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/main/java/io/sentry/spring/checkin/SentryCheckIn.java b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryCheckIn.java index e3fbb018511..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 @@ -31,14 +31,13 @@ /** * Whether to send the schedule and zone from the method's {@code @Scheduled} with check-ins, so - * Sentry creates or updates the monitor. Set to false to manage the monitor's schedule in Sentry - * instead. + * Sentry creates or updates the monitor. * *

Heartbeat check-ins never send a monitor config. * - * @return true to send a monitor config, true by default + * @return true to send a monitor config, false by default */ - boolean upsertMonitorConfig() default true; + 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 202bd30e56a..d43d72a03bb 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 @@ -31,6 +31,7 @@ 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; @@ -152,7 +153,8 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl private @Nullable MonitorConfig monitorConfigFromScheduled(final @NotNull Method method) { try { final @NotNull Set schedules = - AnnotatedElementUtils.findMergedRepeatableAnnotations(method, Scheduled.class); + AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class); if (schedules.size() != 1) { return null; } 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 b6e8a4e3de2..0bac0df528c 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/SentryCheckInAdviceTest.kt @@ -68,6 +68,8 @@ class SentryCheckInAdviceTest { @Autowired lateinit var sampleServiceScheduled: SampleServiceScheduled + @Autowired lateinit var sampleServiceInheritedSchedule: SampleServiceInheritedSchedule + @Autowired lateinit var scopes: IScopes val lifecycleToken = mock() @@ -371,6 +373,16 @@ class SentryCheckInAdviceTest { 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() @@ -406,6 +418,8 @@ class SentryCheckInAdviceTest { @Bean open fun sampleServiceScheduled() = SampleServiceScheduled() + @Bean open fun sampleServiceInheritedSchedule() = SampleServiceInheritedSchedule() + @Bean open fun scopes(): IScopes { val scopes = mock() @@ -458,57 +472,63 @@ class SentryCheckInAdviceTest { open class SampleServiceScheduled { - @SentryCheckIn("cron_zone") + @SentryCheckIn("cron_zone", upsertMonitorConfig = true) @Scheduled(cron = "0 15 10 * * MON-FRI", zone = "Europe/Vienna") open fun cronWithZone() {} - @SentryCheckIn("cron") @Scheduled(cron = "0 0 2 * * *") open fun cron() {} + @SentryCheckIn("cron", upsertMonitorConfig = true) + @Scheduled(cron = "0 0 2 * * *") + open fun cron() {} - @SentryCheckIn("cron_properties") + @SentryCheckIn("cron_properties", upsertMonitorConfig = true) @Scheduled(cron = "\${my.cron.schedule}", zone = "\${my.cron.zone}") open fun cronFromProperties() {} - @SentryCheckIn("fixed_rate_hours") + @SentryCheckIn("fixed_rate_hours", upsertMonitorConfig = true) @Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS) open fun fixedRateHours() {} - @SentryCheckIn("fixed_rate_iso") + @SentryCheckIn("fixed_rate_iso", upsertMonitorConfig = true) @Scheduled(fixedRateString = "PT10M") open fun fixedRateIso() {} - @SentryCheckIn("fixed_rate_simple") + @SentryCheckIn("fixed_rate_simple", upsertMonitorConfig = true) @Scheduled(fixedRateString = "5m") open fun fixedRateSimpleDuration() {} - @SentryCheckIn("fixed_delay") @Scheduled(fixedDelay = 300_000) open fun fixedDelay() {} + @SentryCheckIn("fixed_delay", upsertMonitorConfig = true) + @Scheduled(fixedDelay = 300_000) + open fun fixedDelay() {} - @SentryCheckIn("fixed_delay_iso") + @SentryCheckIn("fixed_delay_iso", upsertMonitorConfig = true) @Scheduled(fixedDelayString = "PT10M") open fun fixedDelayIso() {} - @SentryCheckIn("disabled_cron") @Scheduled(cron = "-") open fun disabledCron() {} + @SentryCheckIn("disabled_cron", upsertMonitorConfig = true) + @Scheduled(cron = "-") + open fun disabledCron() {} - @SentryCheckIn("cron_empty_zone") + @SentryCheckIn("cron_empty_zone", upsertMonitorConfig = true) @Scheduled(cron = "0 0 3 * * *", zone = "\${my.cron.empty.zone}") open fun cronWithEmptyZone() {} - @SentryCheckIn("cron_zone_defaults") + @SentryCheckIn("cron_zone_defaults", upsertMonitorConfig = true) @Scheduled(cron = "0 0 4 * * *", zone = "Europe/Vienna") open fun cronWithZoneAndDefaults() {} - @SentryCheckIn("schedules_container") + @SentryCheckIn("schedules_container", upsertMonitorConfig = true) @Schedules(Scheduled(cron = "0 0 1 * * *"), Scheduled(cron = "0 0 13 * * *")) open fun schedulesContainer() {} - @SentryCheckIn("unresolvable_cron") + @SentryCheckIn("unresolvable_cron", upsertMonitorConfig = true) @Scheduled(cron = "\${my.cron.missing}") open fun unresolvableCron() {} - @SentryCheckIn("derivation_throws") + @SentryCheckIn("derivation_throws", upsertMonitorConfig = true) @Scheduled(cron = "\${my.cron.exception.property}") open fun derivationThrows() = 1 - @SentryCheckIn("multiple") + @SentryCheckIn("multiple", upsertMonitorConfig = true) @Scheduled(cron = "0 0 1 * * *") @Scheduled(cron = "0 0 13 * * *") open fun multipleSchedules() {} @@ -517,11 +537,21 @@ class SentryCheckInAdviceTest { @Scheduled(cron = "0 0 1 * * *") open fun upsertDisabled() {} - @SentryCheckIn("heartbeat", heartbeat = true) + @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( From a496af8aa0d4ee0ab80bb9985049cb60db486d29 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 11:39:53 -0500 Subject: [PATCH 28/38] fix(spring): Find @Scheduled tasks to monitor the way Spring does An overriding method without @Scheduled isn't scheduled by Spring, so don't monitor it either. --- .../jakarta/checkin/SentryCheckInAdvice.java | 3 ++- .../checkin/SentryScheduledCheckInTest.kt | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) 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 116cca9409d..a1599a7b634 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 @@ -185,7 +185,8 @@ private Object captureCheckIns( } static boolean isScheduled(final @NotNull Method method) { - return !AnnotatedElementUtils.findMergedRepeatableAnnotations(method, Scheduled.class) + return !AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class) .isEmpty(); } 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 index 9d36cbb5005..107ccceb7a3 100644 --- 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 @@ -52,6 +52,8 @@ class SentryScheduledCheckInTest { @Autowired lateinit var finalJobs: FinalJobs + @Autowired lateinit var overridingJobs: OverridingJobs + @Autowired lateinit var scopes: IScopes @BeforeTest @@ -105,6 +107,7 @@ class SentryScheduledCheckInTest { 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 @@ -113,6 +116,12 @@ class SentryScheduledCheckInTest { 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() @@ -194,6 +203,8 @@ class SentryScheduledCheckInTest { @Bean open fun finalJobs() = FinalJobs() + @Bean open fun overridingJobs() = OverridingJobs() + @Bean open fun scopes(): IScopes = mock() @Bean @@ -252,4 +263,12 @@ class SentryScheduledCheckInTest { 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() {} + } } From 2f0e28528e1f4d45b7949630b57b2845f3360757 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 11:40:51 -0500 Subject: [PATCH 29/38] fix(spring): Find @Scheduled tasks to monitor the way Spring does An overriding method without @Scheduled isn't scheduled by Spring, so don't monitor it either. --- .../spring/checkin/SentryCheckInAdvice.java | 3 ++- .../checkin/SentryScheduledCheckInTest.kt | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) 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 a2698412982..bc85f7c471c 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 @@ -188,7 +188,8 @@ private Object captureCheckIns( } static boolean isScheduled(final @NotNull Method method) { - return !AnnotatedElementUtils.findMergedRepeatableAnnotations(method, Scheduled.class) + return !AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class) .isEmpty(); } 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 index 5a0ed4c986f..1a3bd741fe3 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt @@ -52,6 +52,8 @@ class SentryScheduledCheckInTest { @Autowired lateinit var finalJobs: FinalJobs + @Autowired lateinit var overridingJobs: OverridingJobs + @Autowired lateinit var scopes: IScopes @BeforeTest @@ -105,6 +107,7 @@ class SentryScheduledCheckInTest { 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 @@ -113,6 +116,12 @@ class SentryScheduledCheckInTest { 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() @@ -194,6 +203,8 @@ class SentryScheduledCheckInTest { @Bean open fun finalJobs() = FinalJobs() + @Bean open fun overridingJobs() = OverridingJobs() + @Bean open fun scopes(): IScopes = mock() @Bean @@ -252,4 +263,12 @@ class SentryScheduledCheckInTest { 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() {} + } } From f73407008d6961bb266ac60b42cdfa4505770968 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 11:41:39 -0500 Subject: [PATCH 30/38] fix(spring): Find @Scheduled tasks to monitor the way Spring does An overriding method without @Scheduled isn't scheduled by Spring, so don't monitor it either. --- .../spring7/checkin/SentryCheckInAdvice.java | 3 ++- .../checkin/SentryScheduledCheckInTest.kt | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) 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 4f7133b1d3d..abe04e996d9 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 @@ -189,7 +189,8 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes, final boolean monitorS } static boolean isScheduled(final @NotNull Method method) { - return !AnnotatedElementUtils.findMergedRepeatableAnnotations(method, Scheduled.class) + return !AnnotatedElementUtils.getMergedRepeatableAnnotations( + method, Scheduled.class, Schedules.class) .isEmpty(); } 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 index 7db4c0ccaa5..9c1c046a025 100644 --- 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 @@ -52,6 +52,8 @@ class SentryScheduledCheckInTest { @Autowired lateinit var finalJobs: FinalJobs + @Autowired lateinit var overridingJobs: OverridingJobs + @Autowired lateinit var scopes: IScopes @BeforeTest @@ -105,6 +107,7 @@ class SentryScheduledCheckInTest { 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 @@ -113,6 +116,12 @@ class SentryScheduledCheckInTest { 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() @@ -194,6 +203,8 @@ class SentryScheduledCheckInTest { @Bean open fun finalJobs() = FinalJobs() + @Bean open fun overridingJobs() = OverridingJobs() + @Bean open fun scopes(): IScopes = mock() @Bean @@ -252,4 +263,12 @@ class SentryScheduledCheckInTest { 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() {} + } } From d9ba6072950f8f49bff8e9a345a5a1c2c822a30d Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 15:13:52 -0500 Subject: [PATCH 31/38] feat(spring): Warn when two @Scheduled methods share a monitor slug Derived slugs use only the short class name and method name, so different methods can map to the same slug and share one monitor. Log a warning the first time that happens so users can set an explicit slug. --- .../jakarta/checkin/SentryCheckInAdvice.java | 28 +++++++- .../checkin/SentryScheduledCheckInTest.kt | 70 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) 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 a1599a7b634..93f10373bd8 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 @@ -66,6 +66,10 @@ public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueReso 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()); } @@ -106,7 +110,9 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl taskName); return invocation.proceed(); } - return captureCheckIns(invocation, scheduledTaskSlug(taskName), false, monitorConfig); + final @NotNull String monitorSlug = scheduledTaskSlug(taskName); + warnOnSlugCollision(monitorSlug, invocation.getThis().getClass(), mostSpecificMethod); + return captureCheckIns(invocation, monitorSlug, false, monitorConfig); } return invocation.proceed(); } @@ -184,6 +190,26 @@ private Object captureCheckIns( } } + 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) 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 index 107ccceb7a3..9de185febdf 100644 --- 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 @@ -2,8 +2,10 @@ 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 kotlin.test.BeforeTest @@ -18,13 +20,16 @@ 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 @@ -45,6 +50,8 @@ import reactor.core.publisher.Mono @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 @@ -185,6 +192,61 @@ class SentryScheduledCheckInTest { ) } + @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() @@ -256,6 +318,14 @@ class SentryScheduledCheckInTest { @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 ExcludedJobs { @Scheduled(cron = "0 0 2 * * *") open fun run() {} } From f96738e6627375fe530115cff9e294535fbcd767 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 15:14:46 -0500 Subject: [PATCH 32/38] feat(spring): Warn when two @Scheduled methods share a monitor slug Derived slugs use only the short class name and method name, so different methods can map to the same slug and share one monitor. Log a warning the first time that happens so users can set an explicit slug. --- .../spring/checkin/SentryCheckInAdvice.java | 28 +++++++- .../checkin/SentryScheduledCheckInTest.kt | 70 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) 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 bc85f7c471c..dd46429fea5 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 @@ -66,6 +66,10 @@ public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueReso 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()); } @@ -106,7 +110,9 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl taskName); return invocation.proceed(); } - return captureCheckIns(invocation, scheduledTaskSlug(taskName), false, monitorConfig); + final @NotNull String monitorSlug = scheduledTaskSlug(taskName); + warnOnSlugCollision(monitorSlug, invocation.getThis().getClass(), mostSpecificMethod); + return captureCheckIns(invocation, monitorSlug, false, monitorConfig); } return invocation.proceed(); } @@ -187,6 +193,26 @@ private Object captureCheckIns( } } + 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) 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 index 1a3bd741fe3..5cc7017a11c 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt @@ -2,8 +2,10 @@ 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 kotlin.test.BeforeTest @@ -18,13 +20,16 @@ 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 @@ -45,6 +50,8 @@ import reactor.core.publisher.Mono @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 @@ -185,6 +192,61 @@ class SentryScheduledCheckInTest { ) } + @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() @@ -256,6 +318,14 @@ class SentryScheduledCheckInTest { @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 ExcludedJobs { @Scheduled(cron = "0 0 2 * * *") open fun run() {} } From cc9e28a4cf817631342bb5392e49b6538c04e516 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 15:15:50 -0500 Subject: [PATCH 33/38] feat(spring): Warn when two @Scheduled methods share a monitor slug Derived slugs use only the short class name and method name, so different methods can map to the same slug and share one monitor. Log a warning the first time that happens so users can set an explicit slug. --- .../spring7/checkin/SentryCheckInAdvice.java | 35 ++++++++-- .../checkin/SentryScheduledCheckInTest.kt | 70 +++++++++++++++++++ 2 files changed, 100 insertions(+), 5 deletions(-) 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 abe04e996d9..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 @@ -66,6 +66,10 @@ public class SentryCheckInAdvice implements MethodInterceptor, EmbeddedValueReso 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()); } @@ -95,10 +99,9 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes, final boolean monitorS AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); if (checkInAnnotation == null) { if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) { - final @NotNull String taskName = - scheduledTaskName( - target != null ? target.getClass() : mostSpecificMethod.getDeclaringClass(), - 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 @@ -110,7 +113,9 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes, final boolean monitorS taskName); return invocation.proceed(); } - return captureCheckIns(invocation, scheduledTaskSlug(taskName), false, monitorConfig); + final @NotNull String monitorSlug = scheduledTaskSlug(taskName); + warnOnSlugCollision(monitorSlug, targetClass, mostSpecificMethod); + return captureCheckIns(invocation, monitorSlug, false, monitorConfig); } return invocation.proceed(); } @@ -188,6 +193,26 @@ public SentryCheckInAdvice(final @NotNull IScopes scopes, final boolean monitorS } } + 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) 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 index 9c1c046a025..cdc500d389b 100644 --- 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 @@ -2,8 +2,10 @@ 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 kotlin.test.BeforeTest @@ -18,13 +20,16 @@ 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 @@ -45,6 +50,8 @@ import reactor.core.publisher.Mono @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 @@ -185,6 +192,61 @@ class SentryScheduledCheckInTest { ) } + @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() @@ -256,6 +318,14 @@ class SentryScheduledCheckInTest { @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 ExcludedJobs { @Scheduled(cron = "0 0 2 * * *") open fun run() {} } From 09e716075f3f9e1708b45f95196c57d39a3e583d Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 15:28:16 -0500 Subject: [PATCH 34/38] ref(spring): Reuse the target class for the slug collision check Use the same class for the task name and the collision check so both see one value. --- .../sentry/spring/jakarta/checkin/SentryCheckInAdvice.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 93f10373bd8..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 @@ -97,8 +97,8 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); if (checkInAnnotation == null) { if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) { - final @NotNull String taskName = - scheduledTaskName(invocation.getThis().getClass(), 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 @@ -111,7 +111,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl return invocation.proceed(); } final @NotNull String monitorSlug = scheduledTaskSlug(taskName); - warnOnSlugCollision(monitorSlug, invocation.getThis().getClass(), mostSpecificMethod); + warnOnSlugCollision(monitorSlug, targetClass, mostSpecificMethod); return captureCheckIns(invocation, monitorSlug, false, monitorConfig); } return invocation.proceed(); From 233f5b06f774e4103f2a7042867a08f672933833 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 15:28:21 -0500 Subject: [PATCH 35/38] ref(spring): Reuse the target class for the slug collision check Use the same class for the task name and the collision check so both see one value. --- .../java/io/sentry/spring/checkin/SentryCheckInAdvice.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 dd46429fea5..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 @@ -97,8 +97,8 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl AnnotationUtils.findAnnotation(mostSpecificMethod, SentryCheckIn.class); if (checkInAnnotation == null) { if (monitorScheduledTasks && isScheduled(mostSpecificMethod)) { - final @NotNull String taskName = - scheduledTaskName(invocation.getThis().getClass(), 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 @@ -111,7 +111,7 @@ public Object invoke(final @NotNull MethodInvocation invocation) throws Throwabl return invocation.proceed(); } final @NotNull String monitorSlug = scheduledTaskSlug(taskName); - warnOnSlugCollision(monitorSlug, invocation.getThis().getClass(), mostSpecificMethod); + warnOnSlugCollision(monitorSlug, targetClass, mostSpecificMethod); return captureCheckIns(invocation, monitorSlug, false, monitorConfig); } return invocation.proceed(); From 2a61e067096e5e72a91031b6e044be700b280640 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 16:02:48 -0500 Subject: [PATCH 36/38] fix(spring): Skip private and static @Scheduled methods when monitoring all tasks Spring invokes private and static scheduled methods on the target, so the proxy never sees them. Don't match them, so they aren't proxied for nothing. --- .../SentryScheduledCheckInPointcut.java | 8 +++++-- .../checkin/SentryScheduledCheckInTest.kt | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) 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 index b0af348a275..96b4098c2aa 100644 --- 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 @@ -35,8 +35,12 @@ public SentryScheduledCheckInPointcut(final @Nullable List excludedSched @Override public boolean matches(final @NotNull Method method, final @NotNull Class targetClass) { final @NotNull Class userClass = ClassUtils.getUserClass(targetClass); - // CGLIB can't intercept these - if (Modifier.isFinal(userClass.getModifiers()) || Modifier.isFinal(method.getModifiers())) { + // 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); 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 index 9de185febdf..00a2864b3f3 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -151,6 +152,18 @@ class SentryScheduledCheckInTest { 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( @@ -326,6 +339,14 @@ class SentryScheduledCheckInTest { @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() {} } From 8ae1938034e26b8aeb18d57dcca940ac9a987420 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 16:02:53 -0500 Subject: [PATCH 37/38] fix(spring): Skip private and static @Scheduled methods when monitoring all tasks Spring invokes private and static scheduled methods on the target, so the proxy never sees them. Don't match them, so they aren't proxied for nothing. --- .../SentryScheduledCheckInPointcut.java | 8 +++++-- .../checkin/SentryScheduledCheckInTest.kt | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) 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 index ef3edbd2225..16412849f61 100644 --- a/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryScheduledCheckInPointcut.java +++ b/sentry-spring/src/main/java/io/sentry/spring/checkin/SentryScheduledCheckInPointcut.java @@ -35,8 +35,12 @@ public SentryScheduledCheckInPointcut(final @Nullable List excludedSched @Override public boolean matches(final @NotNull Method method, final @NotNull Class targetClass) { final @NotNull Class userClass = ClassUtils.getUserClass(targetClass); - // CGLIB can't intercept these - if (Modifier.isFinal(userClass.getModifiers()) || Modifier.isFinal(method.getModifiers())) { + // 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); 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 index 5cc7017a11c..1af18fe95f0 100644 --- a/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt +++ b/sentry-spring/src/test/kotlin/io/sentry/spring/checkin/SentryScheduledCheckInTest.kt @@ -8,6 +8,7 @@ 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 @@ -151,6 +152,18 @@ class SentryScheduledCheckInTest { 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( @@ -326,6 +339,14 @@ class SentryScheduledCheckInTest { @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() {} } From 4484aa3d765f69077884967cefc3cbc3f99130d0 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Thu, 5 Nov 2026 16:02:57 -0500 Subject: [PATCH 38/38] fix(spring): Skip private and static @Scheduled methods when monitoring all tasks Spring invokes private and static scheduled methods on the target, so the proxy never sees them. Don't match them, so they aren't proxied for nothing. --- .../SentryScheduledCheckInPointcut.java | 8 +++++-- .../checkin/SentryScheduledCheckInTest.kt | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) 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 index 229d3366720..2fc1f11da17 100644 --- 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 @@ -35,8 +35,12 @@ public SentryScheduledCheckInPointcut(final @Nullable List excludedSched @Override public boolean matches(final @NotNull Method method, final @NotNull Class targetClass) { final @NotNull Class userClass = ClassUtils.getUserClass(targetClass); - // CGLIB can't intercept these - if (Modifier.isFinal(userClass.getModifiers()) || Modifier.isFinal(method.getModifiers())) { + // 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); 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 index cdc500d389b..2e60dbcc556 100644 --- 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 @@ -8,6 +8,7 @@ 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 @@ -151,6 +152,18 @@ class SentryScheduledCheckInTest { 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( @@ -326,6 +339,14 @@ class SentryScheduledCheckInTest { @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() {} }