Skip to content

Commit ae12a07

Browse files
amurekicodingjoe
andauthored
Auto-upsert Sentry cron monitors for the tasks (#42)
Sentry introduced an ability to automatically create cron monitors in case the monitor config is provided along with the payload: https://docs.sentry.io/platforms/python/crons/#configuring-cron-monitors In case we are positive that Sentry is used within the project, instead of simply sending the signal, we can upsert the config right away to reduce the amount of manual work. Co-authored-by: Johannes Maron <johannes@maron.family>
1 parent 18b4693 commit ae12a07

6 files changed

Lines changed: 195 additions & 28 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,12 @@ def my_interval_task():
104104

105105
### Sentry Cron Monitors
106106

107-
If you use [Sentry] you can add cron monitors to your tasks.
107+
If you use [Sentry] suitable tasks will be automatically monitored.
108108
The monitor's slug will be the actor's name. Like `my_task` in the example above.
109109

110+
Certain triggers are not supported by Sentry as well as sub-minute intervals.
111+
In these cases, no monitor will be created and no telemetry will be sent.
112+
110113
### The crontask command
111114

112115
```ShellSession

crontask/__init__.py

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@
1313
from django.utils import timezone
1414

1515
from . import _version
16+
from .contrib import sentry
17+
18+
__all__ = ["cron", "interval", "scheduler", "VERSION", "__version__"]
1619

1720
__version__ = _version.version
1821
VERSION = _version.version_tuple
1922

20-
__all__ = ["cron", "interval", "scheduler"]
21-
2223

2324
class LazyBlockingScheduler(BlockingScheduler):
2425
"""Avoid annoying info logs for pending jobs."""
@@ -45,21 +46,15 @@ def cron(schedule: str | BaseTrigger) -> typing.Callable[[Task], Task]:
4546
def cron_test():
4647
print("Cron test")
4748
48-
49-
Please don't forget to set up a sentry monitor for the actor, otherwise you won't
50-
get any notifications if the cron job fails.
51-
52-
The monitor slug is your actor name, the schedule should be set to the same
53-
cron schedule as the cron decorator. The schedule type should be set to cron.
54-
The monitors timezone should be set to Europe/Berlin.
49+
Sentry cron monitors are automatically upserted on every check-in
50+
using the task name as the monitor slug.
5551
"""
5652

5753
def decorator(task: Task) -> Task:
58-
trigger = schedule
5954
if isinstance(schedule, str):
6055
*_, day_schedule = schedule.split(" ")
61-
# CronTrigger uses Python's timezone dependent first weekday,
62-
# so in Berlin monday is 0 and sunday is 6. We use literals to avoid
56+
# CronTrigger uses Python's timezone-dependent first weekday,
57+
# so in Berlin Monday is 0, and Sunday is 6. We use literals to avoid
6358
# confusion. Literals are also more readable and crontab conform.
6459
if any(i.isdigit() for i in day_schedule):
6560
raise ValueError(
@@ -69,22 +64,10 @@ def decorator(task: Task) -> Task:
6964
schedule,
7065
timezone=timezone.get_default_timezone(),
7166
)
72-
73-
try:
74-
from sentry_sdk.crons import monitor
75-
except ImportError:
76-
fn = task.func
7767
else:
78-
fn = monitor(task.name)(task.func)
79-
80-
task = type(task)(
81-
priority=task.priority,
82-
func=fn,
83-
queue_name=task.queue_name,
84-
backend=task.backend,
85-
takes_context=task.takes_context,
86-
run_after=task.run_after,
87-
)
68+
trigger = schedule
69+
70+
task = sentry.monitor_cron_task(task, trigger)
8871

8972
scheduler.add_job(
9073
func=task.enqueue,

crontask/contrib/__init__.py

Whitespace-only changes.

crontask/contrib/sentry.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import typing
2+
3+
from apscheduler.triggers.base import BaseTrigger
4+
from apscheduler.triggers.calendarinterval import CalendarIntervalTrigger
5+
from apscheduler.triggers.cron import CronTrigger
6+
from apscheduler.triggers.interval import IntervalTrigger
7+
from django.tasks import Task
8+
from django.utils import timezone
9+
10+
11+
def trigger_to_monitor_config(trigger: BaseTrigger) -> dict[str, typing.Any] | None:
12+
"""Convert an APScheduler trigger to a Sentry monitor configuration if possible."""
13+
tz = str(timezone.get_default_timezone())
14+
match trigger:
15+
case CronTrigger():
16+
fields = {f.name: str(f) for f in trigger.fields}
17+
return {
18+
"schedule": {
19+
"type": "crontab",
20+
"value": "{minute} {hour} {day} {month} {day_of_week}".format(
21+
**fields
22+
),
23+
},
24+
"timezone": tz,
25+
}
26+
case IntervalTrigger():
27+
seconds = trigger.interval.total_seconds()
28+
for unit, size in (
29+
("year", 31536000),
30+
("month", 2592000),
31+
("week", 604800),
32+
("day", 86400),
33+
("hour", 3600),
34+
("minute", 60),
35+
):
36+
if seconds % size == 0:
37+
return {
38+
"schedule": {
39+
"type": "interval",
40+
"value": seconds // size,
41+
"unit": unit,
42+
},
43+
"timezone": tz,
44+
}
45+
return None # Less than a minute
46+
case CalendarIntervalTrigger():
47+
fields = {
48+
"year": trigger.years,
49+
"month": trigger.months,
50+
"week": trigger.weeks,
51+
"day": trigger.days,
52+
}
53+
set_fields = {k: v for k, v in fields.items() if v}
54+
if len(set_fields) == 1:
55+
((unit, value),) = set_fields.items()
56+
return {
57+
"schedule": {"type": "interval", "value": value, "unit": unit},
58+
"timezone": tz,
59+
}
60+
return None
61+
case _:
62+
return None
63+
64+
65+
def monitor_cron_task(task: Task, trigger: BaseTrigger) -> Task:
66+
"""
67+
Wrap the task function in a Sentry monitor for a suitable trigger.
68+
69+
You don't need to create the monitor in advance since
70+
the Sentry monitor configuration is upserted on each task execution.
71+
If the trigger is not supported, the task is returned unchanged.
72+
"""
73+
try:
74+
from sentry_sdk import monitor
75+
except ImportError:
76+
return task
77+
else:
78+
if monitor_config := trigger_to_monitor_config(trigger):
79+
return type(task)(
80+
priority=task.priority,
81+
func=monitor(task.name, monitor_config=monitor_config)(task.func),
82+
queue_name=task.queue_name,
83+
backend=task.backend,
84+
takes_context=task.takes_context,
85+
run_after=task.run_after,
86+
)
87+
88+
return task

tests/contrib/__init__.py

Whitespace-only changes.

tests/contrib/test_sentry.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import datetime
2+
3+
import pytest
4+
from apscheduler.triggers.calendarinterval import CalendarIntervalTrigger
5+
from apscheduler.triggers.cron import CronTrigger
6+
from apscheduler.triggers.date import DateTrigger
7+
from apscheduler.triggers.interval import IntervalTrigger
8+
from crontask.contrib.sentry import trigger_to_monitor_config
9+
from django.utils import timezone
10+
11+
12+
def test_monitor_config__cron_trigger():
13+
assert trigger_to_monitor_config(
14+
CronTrigger.from_crontab("0 2 * * *", timezone=timezone.get_default_timezone())
15+
) == {
16+
"schedule": {"type": "crontab", "value": "0 2 * * *"},
17+
"timezone": "Europe/Berlin",
18+
}
19+
20+
21+
def test_monitor_config__cron_trigger__with_day_of_week():
22+
# Literals are allowed, first day of the week is Sunday:
23+
# https://discord.com/channels/621778831602221064/1496192154702315560
24+
trigger = CronTrigger.from_crontab(
25+
"0 2 * * Mon-Fri", timezone=timezone.get_default_timezone()
26+
)
27+
assert trigger_to_monitor_config(trigger) == {
28+
"schedule": {"type": "crontab", "value": "0 2 * * mon-fri"},
29+
"timezone": "Europe/Berlin",
30+
}
31+
32+
33+
@pytest.mark.parametrize(
34+
"seconds,expected",
35+
[
36+
(60, {"type": "interval", "value": 1, "unit": "minute"}),
37+
(300, {"type": "interval", "value": 5, "unit": "minute"}),
38+
(3600, {"type": "interval", "value": 1, "unit": "hour"}),
39+
(86400, {"type": "interval", "value": 1, "unit": "day"}),
40+
(86400 * 3, {"type": "interval", "value": 3, "unit": "day"}),
41+
(604800, {"type": "interval", "value": 1, "unit": "week"}),
42+
(2592000, {"type": "interval", "value": 1, "unit": "month"}),
43+
(31536000, {"type": "interval", "value": 1, "unit": "year"}),
44+
(13 * 2592000, {"type": "interval", "value": 13, "unit": "month"}),
45+
(12 * 604800, {"type": "interval", "value": 12, "unit": "week"}),
46+
],
47+
)
48+
def test_monitor_config__interval_trigger(seconds, expected):
49+
trigger = IntervalTrigger(seconds=seconds, timezone=timezone.get_default_timezone())
50+
assert trigger_to_monitor_config(trigger) is not None
51+
assert trigger_to_monitor_config(trigger)["schedule"] == expected
52+
53+
54+
@pytest.mark.parametrize(
55+
"seconds",
56+
[
57+
42, # less than 60 seconds
58+
69, # not a multiple of any supported unit
59+
],
60+
)
61+
def test_monitor_config__unsupported_interval_trigger(seconds):
62+
trigger = IntervalTrigger(seconds=seconds, timezone=timezone.get_default_timezone())
63+
assert trigger_to_monitor_config(trigger) is None
64+
65+
66+
@pytest.mark.parametrize(
67+
"kwargs,expected",
68+
[
69+
({"years": 1}, {"type": "interval", "value": 1, "unit": "year"}),
70+
({"months": 2}, {"type": "interval", "value": 2, "unit": "month"}),
71+
({"weeks": 1}, {"type": "interval", "value": 1, "unit": "week"}),
72+
({"days": 7}, {"type": "interval", "value": 7, "unit": "day"}),
73+
],
74+
)
75+
def test_monitor_config__calendar_interval_trigger(kwargs, expected):
76+
trigger = CalendarIntervalTrigger(
77+
**kwargs, timezone=timezone.get_default_timezone()
78+
)
79+
assert trigger_to_monitor_config(trigger)["schedule"] == expected
80+
81+
82+
@pytest.mark.parametrize(
83+
"trigger",
84+
[
85+
IntervalTrigger(seconds=30, timezone=timezone.get_default_timezone()),
86+
CalendarIntervalTrigger(
87+
months=1, days=1, timezone=timezone.get_default_timezone()
88+
),
89+
DateTrigger(run_date=datetime.datetime(2021, 1, 1)),
90+
],
91+
)
92+
def test_monitor_config__unsupported(trigger):
93+
assert trigger_to_monitor_config(trigger) is None

0 commit comments

Comments
 (0)