diff --git a/README.md b/README.md index 93a19fe..f728e9f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crontask/__init__.py b/crontask/__init__.py index c24b56f..d3eab36 100644 --- a/crontask/__init__.py +++ b/crontask/__init__.py @@ -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, +) -> typing.Callable[[Task], Task]: """ Run task on a scheduler with a cron schedule. @@ -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: @@ -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, + ) scheduler.add_job( func=task.enqueue, diff --git a/crontask/contrib/sentry.py b/crontask/contrib/sentry.py index 453cedc..a1da310 100644 --- a/crontask/contrib/sentry.py +++ b/crontask/contrib/sentry.py @@ -1,5 +1,3 @@ -import typing - from apscheduler.triggers.base import BaseTrigger from apscheduler.triggers.calendarinterval import CalendarIntervalTrigger from apscheduler.triggers.cron import CronTrigger @@ -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: @@ -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: """ Wrap the task function in a Sentry monitor for a suitable trigger. @@ -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 + ): return type(task)( priority=task.priority, func=monitor(task.name, monitor_config=monitor_config)(task.func), diff --git a/tests/contrib/test_sentry.py b/tests/contrib/test_sentry.py index f5a0729..e0387d4 100644 --- a/tests/contrib/test_sentry.py +++ b/tests/contrib/test_sentry.py @@ -1,4 +1,5 @@ import datetime +from unittest.mock import Mock, patch import pytest from apscheduler.triggers.calendarinterval import CalendarIntervalTrigger @@ -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 diff --git a/tests/test_tasks.py b/tests/test_tasks.py index e2e50cf..b4bd775 100644 --- a/tests/test_tasks.py +++ b/tests/test_tasks.py @@ -1,5 +1,6 @@ import datetime import zoneinfo +from unittest.mock import Mock import pytest from apscheduler.triggers.interval import IntervalTrigger @@ -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