Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ The monitor's slug will be the actor's name. Like `my_task` in the example above
Certain triggers are not supported by Sentry as well as sub-minute intervals.
In these cases, no monitor will be created and no telemetry will be sent.

Pass a custom config via `sentry_monitor_config` or set it to `False`
to disable Sentry monitoring for a single task:

```python
@cron("* * * * *", sentry_monitor_config=False)
@task
def my_task_without_sentry(): ...
```

### The crontask command

```ShellSession
Expand Down
19 changes: 15 additions & 4 deletions crontask/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ def add_job(self, *args, **kwargs):
scheduler = LazyBlockingScheduler()


def cron(schedule: str | BaseTrigger) -> typing.Callable[[Task], Task]:
def cron(
schedule: str | BaseTrigger,
*,
sentry_monitor_config: dict[str, typing.Any] | bool | None = None,
Comment thread
codingjoe marked this conversation as resolved.
) -> typing.Callable[[Task], Task]:
"""
Run task on a scheduler with a cron schedule.

Expand All @@ -46,8 +50,10 @@ def cron(schedule: str | BaseTrigger) -> typing.Callable[[Task], Task]:
def cron_test():
print("Cron test")

Sentry cron monitors are automatically upserted on every check-in
using the task name as the monitor slug.
Args:
schedule: A cron schedule string or an APScheduler trigger.
sentry_monitor_config: A Sentry monitor configuration dict or False to disable monitoring.

"""

def decorator(task: Task) -> Task:
Expand All @@ -67,7 +73,12 @@ def decorator(task: Task) -> Task:
else:
trigger = schedule

task = sentry.monitor_cron_task(task, trigger)
if sentry_monitor_config is not False:
task = sentry.monitor_cron_task(
task,
trigger,
sentry_monitor_config=sentry_monitor_config,
)
Comment thread
codingjoe marked this conversation as resolved.

scheduler.add_job(
func=task.enqueue,
Expand Down
16 changes: 11 additions & 5 deletions crontask/contrib/sentry.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import typing

from apscheduler.triggers.base import BaseTrigger
from apscheduler.triggers.calendarinterval import CalendarIntervalTrigger
from apscheduler.triggers.cron import CronTrigger
Expand All @@ -8,7 +6,9 @@
from django.utils import timezone


def trigger_to_monitor_config(trigger: BaseTrigger) -> dict[str, typing.Any] | None:
def trigger_to_monitor_config(
trigger: BaseTrigger,
) -> dict[str, dict[str, str | int] | str] | None:
"""Convert an APScheduler trigger to a Sentry monitor configuration if possible."""
tz = str(timezone.get_default_timezone())
match trigger:
Expand Down Expand Up @@ -62,7 +62,11 @@ def trigger_to_monitor_config(trigger: BaseTrigger) -> dict[str, typing.Any] | N
return None


def monitor_cron_task(task: Task, trigger: BaseTrigger) -> Task:
def monitor_cron_task(
task: Task,
trigger: BaseTrigger,
sentry_monitor_config: dict[str, dict[str, str | int] | str] | None = None,
) -> Task:
Comment thread
codingjoe marked this conversation as resolved.
"""
Wrap the task function in a Sentry monitor for a suitable trigger.

Expand All @@ -75,7 +79,9 @@ def monitor_cron_task(task: Task, trigger: BaseTrigger) -> Task:
except ImportError:
return task
else:
if monitor_config := trigger_to_monitor_config(trigger):
if monitor_config := sentry_monitor_config or trigger_to_monitor_config(
trigger
):
Comment thread
codingjoe marked this conversation as resolved.
return type(task)(
priority=task.priority,
func=monitor(task.name, monitor_config=monitor_config)(task.func),
Expand Down
91 changes: 91 additions & 0 deletions tests/contrib/test_sentry.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import datetime
from unittest.mock import Mock, patch

import pytest
from apscheduler.triggers.calendarinterval import CalendarIntervalTrigger
Expand Down Expand Up @@ -91,3 +92,93 @@ def test_monitor_config__calendar_interval_trigger(kwargs, expected):
)
def test_monitor_config__unsupported(trigger):
assert trigger_to_monitor_config(trigger) is None


def test_monitor_cron_task__custom_config():
"""Use custom Sentry monitor config when provided."""
pytest.importorskip("sentry_sdk")
from crontask.contrib.sentry import monitor_cron_task
from crontask.tasks import heartbeat

cron_trigger = CronTrigger.from_crontab(
"0 2 * * *", timezone=timezone.get_default_timezone()
)
custom_config = {"schedule": {"type": "crontab", "value": "0 2 * * *"}}

mock_monitor_decorator = Mock(return_value=lambda f: f)
with patch("sentry_sdk.monitor", mock_monitor_decorator):
result = monitor_cron_task(
heartbeat, cron_trigger, sentry_monitor_config=custom_config
)

mock_monitor_decorator.assert_called_once_with(
heartbeat.name, monitor_config=custom_config
)
assert result is not heartbeat


def test_monitor_cron_task__auto_detect_config():
"""Auto-detect Sentry monitor config when not provided."""
pytest.importorskip("sentry_sdk")
from crontask.contrib.sentry import monitor_cron_task
from crontask.tasks import heartbeat

cron_trigger = CronTrigger.from_crontab(
"0 2 * * *", timezone=timezone.get_default_timezone()
)

mock_monitor_decorator = Mock(return_value=lambda f: f)
with patch("sentry_sdk.monitor", mock_monitor_decorator):
result = monitor_cron_task(heartbeat, cron_trigger, sentry_monitor_config=None)

expected_config = {
"schedule": {"type": "crontab", "value": "0 2 * * *"},
"timezone": "Europe/Berlin",
}
mock_monitor_decorator.assert_called_once_with(
heartbeat.name, monitor_config=expected_config
)
assert result is not heartbeat


def test_monitor_cron_task__unsupported_trigger_returns_task_unchanged():
"""Return task unchanged when trigger is unsupported and no custom config provided."""
pytest.importorskip("sentry_sdk")
from crontask.contrib.sentry import monitor_cron_task
from crontask.tasks import heartbeat

unsupported_trigger = IntervalTrigger(
seconds=30, timezone=timezone.get_default_timezone()
)

mock_monitor_decorator = Mock()
with patch("sentry_sdk.monitor", mock_monitor_decorator):
result = monitor_cron_task(
heartbeat, unsupported_trigger, sentry_monitor_config=None
)

mock_monitor_decorator.assert_not_called()
assert result is heartbeat


def test_monitor_cron_task__custom_config_overrides_auto_detect():
"""Custom config takes precedence over auto-detection."""
pytest.importorskip("sentry_sdk")
from crontask.contrib.sentry import monitor_cron_task
from crontask.tasks import heartbeat

cron_trigger = CronTrigger.from_crontab(
"0 2 * * *", timezone=timezone.get_default_timezone()
)
custom_config = {"custom": "config"}

mock_monitor_decorator = Mock(return_value=lambda f: f)
with patch("sentry_sdk.monitor", mock_monitor_decorator):
result = monitor_cron_task(
heartbeat, cron_trigger, sentry_monitor_config=custom_config
)

mock_monitor_decorator.assert_called_once_with(
heartbeat.name, monitor_config=custom_config
)
assert result is not heartbeat
48 changes: 48 additions & 0 deletions tests/test_tasks.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import datetime
import zoneinfo
from unittest.mock import Mock

import pytest
from apscheduler.triggers.interval import IntervalTrigger
Expand Down Expand Up @@ -119,3 +120,50 @@ def test_interval__seconds():
assert scheduler.get_jobs()[0].trigger.get_next_fire_time(
init, init
) == datetime.datetime(2021, 1, 1, 0, 0, 30, tzinfo=DEFAULT_TZINFO)


def test_cron__sentry_monitor_config_false(monkeypatch):
"""Disable Sentry monitoring for a task."""
assert not scheduler.remove_all_jobs()
mock_monitor = Mock(return_value=tasks.heartbeat)
monkeypatch.setattr("crontask.sentry.monitor_cron_task", mock_monitor)

cron("* * * * *", sentry_monitor_config=False)(tasks.heartbeat)

mock_monitor.assert_not_called()
assert len(scheduler.get_jobs()) == 1


def test_cron__sentry_monitor_config_custom_dict(monkeypatch):
"""Use custom Sentry monitor config."""
assert not scheduler.remove_all_jobs()
original_task = tasks.heartbeat
wrapped_task = Mock(spec=type(tasks.heartbeat))
wrapped_task.enqueue = Mock()
wrapped_task.name = original_task.name
mock_monitor = Mock(return_value=wrapped_task)
monkeypatch.setattr("crontask.sentry.monitor_cron_task", mock_monitor)

custom_config = {"schedule": {"type": "crontab", "value": "0 0 * * *"}}
cron("* * * * *", sentry_monitor_config=custom_config)(original_task)

mock_monitor.assert_called_once()
call_args = mock_monitor.call_args
assert call_args.kwargs["sentry_monitor_config"] == custom_config


def test_cron__sentry_monitor_config_none(monkeypatch):
"""Use default Sentry monitor config (auto-detection)."""
assert not scheduler.remove_all_jobs()
original_task = tasks.heartbeat
wrapped_task = Mock(spec=type(tasks.heartbeat))
wrapped_task.enqueue = Mock()
wrapped_task.name = original_task.name
mock_monitor = Mock(return_value=wrapped_task)
monkeypatch.setattr("crontask.sentry.monitor_cron_task", mock_monitor)

cron("* * * * *")(original_task)

mock_monitor.assert_called_once()
call_args = mock_monitor.call_args
assert call_args.kwargs["sentry_monitor_config"] is None
Loading