From ea62aa186978c0b0c993f44a9bec5806acfcb518 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 2 Oct 2026 12:53:47 -0700 Subject: [PATCH 1/6] 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 01b068ee68..bd41c38340 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 0000000000..584227c4e7 --- /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 0000000000..a6c471763f --- /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 2/6] 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 584227c4e7..a96def263a 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 a6c471763f..cee9d2a714 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 3/6] 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 bd41c38340..57510ebf0a 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 a96def263a..b1ddedd3d4 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 cee9d2a714..fd476ceb4e 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 4/6] 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 b1ddedd3d4..8d4bd832d4 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 fd476ceb4e..d6976f7d14 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 5/6] 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 57510ebf0a..a6f573385f 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 8d4bd832d4..fd4b267569 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 d6976f7d14..f3e08ac99f 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 d758789134152e23dd2cfd4f443c2137e5b510fe Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Mon, 5 Oct 2026 17:43:42 -0700 Subject: [PATCH 6/6] 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 fd4b267569..1e033d335e 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 f3e08ac99f..69b8f8833e 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",