Skip to content

Commit 1aab810

Browse files
wedamijaclaude
andauthored
feat(quartz): Send monitor config from the trigger with check-ins (#6216)
* 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. * 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. * 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. * 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. * 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. * feat(spring): Send monitor config from @scheduled with @SentryCheckIn Co-Authored-By: Claude <noreply@anthropic.com> * feat(quartz): Send monitor config from the trigger with check-ins Co-Authored-By: Claude <noreply@anthropic.com> * feat(spring): Send @scheduled monitor config by default Co-Authored-By: Claude <noreply@anthropic.com> * feat(quartz): Send trigger monitor config by default Co-Authored-By: Claude <noreply@anthropic.com> * ref(spring): Shorten upsertMonitorConfig Javadoc Co-Authored-By: Claude <noreply@anthropic.com> * 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 <noreply@anthropic.com> * fix(spring): Only catch runtime exceptions when deriving monitor config Co-Authored-By: Claude <noreply@anthropic.com> * fix(quartz): Catch only expected errors when deriving monitor config Co-Authored-By: Claude <noreply@anthropic.com> * ref(spring): Shorten upsertMonitorConfig Javadoc Co-Authored-By: Claude <noreply@anthropic.com> * ref(spring): Use pattern-matching instanceof for the @scheduled time unit * 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. * 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. * fix(quartz): Send the trigger's monitor config only when opted in Match @SentryCheckIn: existing check-in users keep their monitors unchanged unless they set sentry-upsert-monitor-config to "true". --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 573cf8f commit 1aab810

5 files changed

Lines changed: 514 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
### Features
1111

12+
- Quartz `SentryJobListener` can send a monitor config derived from the trigger with check-ins, so Sentry creates or updates the monitor from code. Set the `sentry-upsert-monitor-config` job or trigger data key to `"true"` to send the schedule ([#6216](https://github.com/getsentry/sentry-java/pull/6216))
1213
- 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))
1314

1415
## 8.63.0

‎sentry-quartz/api/sentry-quartz.api‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public final class io/sentry/quartz/SentryJobListener : org/quartz/JobListener {
77
public static final field SENTRY_CHECK_IN_ID_KEY Ljava/lang/String;
88
public static final field SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY Ljava/lang/String;
99
public static final field SENTRY_SLUG_KEY Ljava/lang/String;
10+
public static final field SENTRY_UPSERT_MONITOR_CONFIG_KEY Ljava/lang/String;
1011
public fun <init> ()V
1112
public fun <init> (Lio/sentry/IScopes;)V
1213
public fun getName ()Ljava/lang/String;

‎sentry-quartz/build.gradle.kts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ dependencies {
3535
testImplementation(libs.kotlin.test.junit)
3636
testImplementation(libs.mockito.kotlin)
3737
testImplementation(libs.mockito.core)
38+
testImplementation(libs.google.truth)
39+
testImplementation(libs.quartz)
3840
}
3941

4042
tasks.withType<JavaCompile>().configureEach {

‎sentry-quartz/src/main/java/io/sentry/quartz/SentryJobListener.java‎

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,33 @@
55
import io.sentry.CheckInStatus;
66
import io.sentry.IScopes;
77
import io.sentry.ISentryLifecycleToken;
8+
import io.sentry.MonitorConfig;
89
import io.sentry.ScopesAdapter;
910
import io.sentry.SentryIntegrationPackageStorage;
1011
import io.sentry.SentryLevel;
1112
import io.sentry.protocol.SentryId;
1213
import io.sentry.util.LifecycleHelper;
14+
import io.sentry.util.MonitorConfigUtils;
1315
import io.sentry.util.Objects;
1416
import io.sentry.util.TracingUtils;
17+
import java.util.Arrays;
18+
import java.util.List;
19+
import java.util.Locale;
20+
import java.util.TimeZone;
21+
import java.util.regex.Pattern;
1522
import org.jetbrains.annotations.ApiStatus;
1623
import org.jetbrains.annotations.NotNull;
1724
import org.jetbrains.annotations.Nullable;
25+
import org.quartz.CronTrigger;
1826
import org.quartz.JobDataMap;
27+
import org.quartz.JobDetail;
1928
import org.quartz.JobExecutionContext;
2029
import org.quartz.JobExecutionException;
2130
import org.quartz.JobListener;
31+
import org.quartz.Scheduler;
32+
import org.quartz.SchedulerException;
33+
import org.quartz.SimpleTrigger;
34+
import org.quartz.Trigger;
2235

2336
@ApiStatus.Experimental
2437
public final class SentryJobListener implements JobListener {
@@ -32,6 +45,19 @@ public final class SentryJobListener implements JobListener {
3245
public static final String SENTRY_SLUG_KEY = "sentry-slug";
3346
public static final String SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY = "sentry-scope-lifecycle";
3447

48+
/**
49+
* Job or trigger data key. Set it to {@code "true"} to send the trigger's schedule with
50+
* check-ins, so Sentry creates or updates the monitor.
51+
*/
52+
public static final String SENTRY_UPSERT_MONITOR_CONFIG_KEY = "sentry-upsert-monitor-config";
53+
54+
private static final @NotNull List<String> DAY_NAMES =
55+
Arrays.asList("SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT");
56+
57+
// Quartz ignores a step after a name, so SUN/2 is every Sunday
58+
private static final @NotNull Pattern NAME_STEP =
59+
Pattern.compile("(^|,)[A-Za-z]{3}(-[A-Za-z]{3})?/");
60+
3561
private final @NotNull IScopes scopes;
3662

3763
public SentryJobListener() {
@@ -55,15 +81,18 @@ public void jobToBeExecuted(final @NotNull JobExecutionContext context) {
5581
if (maybeSlug == null) {
5682
return;
5783
}
84+
final @Nullable MonitorConfig monitorConfig =
85+
shouldUpsertMonitorConfig(context) ? monitorConfigFromTrigger(context) : null;
5886
final @NotNull ISentryLifecycleToken lifecycleToken =
5987
scopes.forkedScopes("SentryJobListener").makeCurrent();
88+
context.put(SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY, lifecycleToken);
6089
TracingUtils.startNewTrace(scopes);
6190
final @NotNull String slug = maybeSlug;
6291
final @NotNull CheckIn checkIn = new CheckIn(slug, CheckInStatus.IN_PROGRESS);
92+
checkIn.setMonitorConfig(monitorConfig);
6393
final @NotNull SentryId checkInId = scopes.captureCheckIn(checkIn);
6494
context.put(SENTRY_CHECK_IN_ID_KEY, checkInId);
6595
context.put(SENTRY_SLUG_KEY, slug);
66-
context.put(SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY, lifecycleToken);
6796
} catch (Throwable t) {
6897
scopes
6998
.getOptions()
@@ -84,6 +113,154 @@ public void jobToBeExecuted(final @NotNull JobExecutionContext context) {
84113
return null;
85114
}
86115

116+
private boolean shouldUpsertMonitorConfig(final @NotNull JobExecutionContext context) {
117+
final @Nullable JobDataMap jobDataMap = context.getMergedJobDataMap();
118+
if (jobDataMap == null) {
119+
return false;
120+
}
121+
final @Nullable Object o = jobDataMap.get(SENTRY_UPSERT_MONITOR_CONFIG_KEY);
122+
return o != null && "true".equalsIgnoreCase(o.toString().trim());
123+
}
124+
125+
private @Nullable MonitorConfig monitorConfigFromTrigger(
126+
final @NotNull JobExecutionContext context) {
127+
try {
128+
final @Nullable Trigger trigger = context.getTrigger();
129+
// Sentry can't express calendar exclusions
130+
if (trigger == null || trigger.getCalendarName() != null) {
131+
return null;
132+
}
133+
// each trigger would overwrite the others' schedule
134+
if (hasSeveralTriggers(context)) {
135+
return null;
136+
}
137+
if (trigger instanceof CronTrigger) {
138+
final @NotNull CronTrigger cronTrigger = (CronTrigger) trigger;
139+
final @Nullable String cron = toSixFieldCron(cronTrigger.getCronExpression());
140+
if (cron == null) {
141+
return null;
142+
}
143+
final @Nullable TimeZone timeZone = cronTrigger.getTimeZone();
144+
return MonitorConfigUtils.fromSchedule(
145+
cron, timeZone == null ? null : timeZone.getID(), null);
146+
}
147+
if (trigger instanceof SimpleTrigger) {
148+
final @NotNull SimpleTrigger simpleTrigger = (SimpleTrigger) trigger;
149+
if (simpleTrigger.getRepeatCount() != SimpleTrigger.REPEAT_INDEFINITELY) {
150+
return null;
151+
}
152+
return MonitorConfigUtils.fromSchedule(null, null, simpleTrigger.getRepeatInterval());
153+
}
154+
return null;
155+
} catch (RuntimeException | SchedulerException | NoSuchMethodError | AbstractMethodError e) {
156+
// Trigger implementations may come from other Quartz versions or third parties
157+
scopes
158+
.getOptions()
159+
.getLogger()
160+
.log(
161+
SentryLevel.WARNING, "Could not derive a monitor config from the Quartz trigger.", e);
162+
return null;
163+
}
164+
}
165+
166+
private static boolean hasSeveralTriggers(final @NotNull JobExecutionContext context)
167+
throws SchedulerException {
168+
final @Nullable Scheduler scheduler = context.getScheduler();
169+
final @Nullable JobDetail jobDetail = context.getJobDetail();
170+
if (scheduler == null || jobDetail == null) {
171+
return false;
172+
}
173+
return scheduler.getTriggersOfJob(jobDetail.getKey()).size() > 1;
174+
}
175+
176+
/**
177+
* Quartz numbers days of week 1-7 from Sunday; crontab uses 0-6. Null if a year is set or a name
178+
* has a step.
179+
*/
180+
static @Nullable String toSixFieldCron(final @Nullable String quartzCron) {
181+
if (quartzCron == null) {
182+
return null;
183+
}
184+
final @NotNull String[] fields = quartzCron.trim().split("\\s+", -1);
185+
if (fields.length == 7 && !"*".equals(fields[6])) {
186+
return null;
187+
}
188+
if (fields.length != 6 && fields.length != 7) {
189+
return null;
190+
}
191+
if (NAME_STEP.matcher(fields[4]).find() || NAME_STEP.matcher(fields[5]).find()) {
192+
return null;
193+
}
194+
final @Nullable String dayOfWeek = toZeroBasedDayOfWeek(fields[5]);
195+
if (dayOfWeek == null) {
196+
return null;
197+
}
198+
final @NotNull StringBuilder cron = new StringBuilder();
199+
for (int i = 0; i < 5; i++) {
200+
cron.append(fields[i]).append(' ');
201+
}
202+
return cron.append(dayOfWeek).toString();
203+
}
204+
205+
private static @Nullable String toZeroBasedDayOfWeek(final @NotNull String field) {
206+
final @NotNull StringBuilder result = new StringBuilder();
207+
for (final @NotNull String item : field.split(",", -1)) {
208+
final @Nullable String converted = toZeroBasedDayOfWeekItem(item.toUpperCase(Locale.ROOT));
209+
if (converted == null) {
210+
return null;
211+
}
212+
if (result.length() > 0) {
213+
result.append(',');
214+
}
215+
result.append(converted);
216+
}
217+
return result.toString();
218+
}
219+
220+
private static @Nullable String toZeroBasedDayOfWeekItem(final @NotNull String item) {
221+
if (item.startsWith("*") || item.startsWith("?")) {
222+
// '*' starts on Sunday in both
223+
return item;
224+
}
225+
final int hash = item.indexOf('#');
226+
if (hash >= 0) {
227+
final @Nullable Integer day = toZeroBasedDay(item.substring(0, hash));
228+
return day == null ? null : day + item.substring(hash);
229+
}
230+
if (item.length() > 1 && item.endsWith("L")) {
231+
final @Nullable Integer day = toZeroBasedDay(item.substring(0, item.length() - 1));
232+
return day == null ? null : day + "L";
233+
}
234+
final int slash = item.indexOf('/');
235+
final @NotNull String days = slash >= 0 ? item.substring(0, slash) : item;
236+
final @NotNull String step = slash >= 0 ? item.substring(slash) : "";
237+
final @NotNull String[] range = days.split("-", -1);
238+
if (range.length > 2) {
239+
return null;
240+
}
241+
final @Nullable Integer start = toZeroBasedDay(range[0]);
242+
if (start == null) {
243+
return null;
244+
}
245+
if (range.length == 2) {
246+
final @Nullable Integer end = toZeroBasedDay(range[1]);
247+
return end == null ? null : start + "-" + end + step;
248+
}
249+
if (slash < 0 || start == 6) {
250+
return String.valueOf(start);
251+
}
252+
// a step from a single day ends on Saturday in Quartz, but on Sunday (7) in crontab
253+
return start + "-6" + step;
254+
}
255+
256+
private static @Nullable Integer toZeroBasedDay(final @NotNull String day) {
257+
if (day.matches("[1-7]")) {
258+
return Integer.parseInt(day) - 1;
259+
}
260+
final int index = DAY_NAMES.indexOf(day);
261+
return index >= 0 ? index : null;
262+
}
263+
87264
@Override
88265
public void jobExecutionVetoed(JobExecutionContext context) {
89266
// do nothing

0 commit comments

Comments
 (0)