Skip to content

Commit 44ad1c7

Browse files
wedamijaclaude
andcommitted
feat(quartz): Send monitor config from the trigger with check-ins
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5d745c7 commit 44ad1c7

5 files changed

Lines changed: 449 additions & 1 deletion

File tree

‎CHANGELOG.md‎

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

55
### Features
66

7+
- Add the `sentry-upsert-monitor-config` job data key to Quartz `SentryJobListener`; set it to `true` to send a monitor config derived from the trigger with check-ins, so Sentry can create the monitor from code ([#6216](https://github.com/getsentry/sentry-java/pull/6216))
78
- 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))
89
- 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))
910

‎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
@@ -34,6 +34,8 @@ dependencies {
3434
testImplementation(libs.kotlin.test.junit)
3535
testImplementation(libs.mockito.kotlin)
3636
testImplementation(libs.mockito.inline)
37+
testImplementation(libs.google.truth)
38+
testImplementation(libs.quartz)
3739
}
3840

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

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

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,30 @@
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;
1927
import org.quartz.JobExecutionContext;
2028
import org.quartz.JobExecutionException;
2129
import org.quartz.JobListener;
30+
import org.quartz.SimpleTrigger;
31+
import org.quartz.Trigger;
2232

2333
@ApiStatus.Experimental
2434
public final class SentryJobListener implements JobListener {
@@ -32,6 +42,16 @@ public final class SentryJobListener implements JobListener {
3242
public static final String SENTRY_SLUG_KEY = "sentry-slug";
3343
public static final String SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY = "sentry-scope-lifecycle";
3444

45+
/** Job data key; set to {@code true} to send a monitor config from the trigger. */
46+
public static final String SENTRY_UPSERT_MONITOR_CONFIG_KEY = "sentry-upsert-monitor-config";
47+
48+
private static final @NotNull List<String> DAY_NAMES =
49+
Arrays.asList("SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT");
50+
51+
// Quartz ignores a step after a name, so SUN/2 is every Sunday
52+
private static final @NotNull Pattern NAME_STEP =
53+
Pattern.compile("(^|,)[A-Za-z]{3}(-[A-Za-z]{3})?/");
54+
3555
private final @NotNull IScopes scopes;
3656

3757
public SentryJobListener() {
@@ -55,15 +75,20 @@ public void jobToBeExecuted(final @NotNull JobExecutionContext context) {
5575
if (maybeSlug == null) {
5676
return;
5777
}
78+
final @Nullable MonitorConfig monitorConfig =
79+
shouldUpsertMonitorConfig(context)
80+
? monitorConfigFromTrigger(context.getTrigger())
81+
: null;
5882
final @NotNull ISentryLifecycleToken lifecycleToken =
5983
scopes.forkedScopes("SentryJobListener").makeCurrent();
84+
context.put(SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY, lifecycleToken);
6085
TracingUtils.startNewTrace(scopes);
6186
final @NotNull String slug = maybeSlug;
6287
final @NotNull CheckIn checkIn = new CheckIn(slug, CheckInStatus.IN_PROGRESS);
88+
checkIn.setMonitorConfig(monitorConfig);
6389
final @NotNull SentryId checkInId = scopes.captureCheckIn(checkIn);
6490
context.put(SENTRY_CHECK_IN_ID_KEY, checkInId);
6591
context.put(SENTRY_SLUG_KEY, slug);
66-
context.put(SENTRY_SCOPE_LIFECYCLE_TOKEN_KEY, lifecycleToken);
6792
} catch (Throwable t) {
6893
scopes
6994
.getOptions()
@@ -84,6 +109,137 @@ public void jobToBeExecuted(final @NotNull JobExecutionContext context) {
84109
return null;
85110
}
86111

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

0 commit comments

Comments
 (0)