Skip to content

Commit 68b9180

Browse files
kwigleysentrivana
andauthored
feat(integrations): Add support for celery-redbeat cron tasks (#2643)
--------- Co-authored-by: Ivana Kellyerova <[email protected]>
1 parent 9bdd029 commit 68b9180

File tree

3 files changed

+117
-0
lines changed

3 files changed

+117
-0
lines changed

sentry_sdk/integrations/celery.py

+62
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@
5656
except ImportError:
5757
raise DidNotEnable("Celery not installed")
5858

59+
try:
60+
from redbeat.schedulers import RedBeatScheduler # type: ignore
61+
except ImportError:
62+
RedBeatScheduler = None
63+
5964

6065
CELERY_CONTROL_FLOW_EXCEPTIONS = (Retry, Ignore, Reject)
6166

@@ -76,6 +81,7 @@ def __init__(
7681

7782
if monitor_beat_tasks:
7883
_patch_beat_apply_entry()
84+
_patch_redbeat_maybe_due()
7985
_setup_celery_beat_signals()
8086

8187
@staticmethod
@@ -535,6 +541,62 @@ def sentry_apply_entry(*args, **kwargs):
535541
Scheduler.apply_entry = sentry_apply_entry
536542

537543

544+
def _patch_redbeat_maybe_due():
545+
# type: () -> None
546+
547+
if RedBeatScheduler is None:
548+
return
549+
550+
original_maybe_due = RedBeatScheduler.maybe_due
551+
552+
def sentry_maybe_due(*args, **kwargs):
553+
# type: (*Any, **Any) -> None
554+
scheduler, schedule_entry = args
555+
app = scheduler.app
556+
557+
celery_schedule = schedule_entry.schedule
558+
monitor_name = schedule_entry.name
559+
560+
hub = Hub.current
561+
integration = hub.get_integration(CeleryIntegration)
562+
if integration is None:
563+
return original_maybe_due(*args, **kwargs)
564+
565+
if match_regex_list(monitor_name, integration.exclude_beat_tasks):
566+
return original_maybe_due(*args, **kwargs)
567+
568+
with hub.configure_scope() as scope:
569+
# When tasks are started from Celery Beat, make sure each task has its own trace.
570+
scope.set_new_propagation_context()
571+
572+
monitor_config = _get_monitor_config(celery_schedule, app, monitor_name)
573+
574+
is_supported_schedule = bool(monitor_config)
575+
if is_supported_schedule:
576+
headers = schedule_entry.options.pop("headers", {})
577+
headers.update(
578+
{
579+
"sentry-monitor-slug": monitor_name,
580+
"sentry-monitor-config": monitor_config,
581+
}
582+
)
583+
584+
check_in_id = capture_checkin(
585+
monitor_slug=monitor_name,
586+
monitor_config=monitor_config,
587+
status=MonitorStatus.IN_PROGRESS,
588+
)
589+
headers.update({"sentry-monitor-check-in-id": check_in_id})
590+
591+
# Set the Sentry configuration in the options of the ScheduleEntry.
592+
# Those will be picked up in `apply_async` and added to the headers.
593+
schedule_entry.options["headers"] = headers
594+
595+
return original_maybe_due(*args, **kwargs)
596+
597+
RedBeatScheduler.maybe_due = sentry_maybe_due
598+
599+
538600
def _setup_celery_beat_signals():
539601
# type: () -> None
540602
task_success.connect(crons_task_success)

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def get_file_text(file_name):
5050
"beam": ["apache-beam>=2.12"],
5151
"bottle": ["bottle>=0.12.13"],
5252
"celery": ["celery>=3"],
53+
"celery-redbeat": ["celery-redbeat>=2"],
5354
"chalice": ["chalice>=1.16.0"],
5455
"clickhouse-driver": ["clickhouse-driver>=0.2.0"],
5556
"django": ["django>=1.8"],

tests/integrations/celery/test_celery_beat_crons.py

+54
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
_get_humanized_interval,
99
_get_monitor_config,
1010
_patch_beat_apply_entry,
11+
_patch_redbeat_maybe_due,
1112
crons_task_success,
1213
crons_task_failure,
1314
crons_task_retry,
@@ -447,3 +448,56 @@ def test_exclude_beat_tasks_option(
447448
# The original Scheduler.apply_entry() is called, AND _get_monitor_config is called.
448449
assert fake_apply_entry.call_count == 1
449450
assert _get_monitor_config.call_count == 1
451+
452+
453+
@pytest.mark.parametrize(
454+
"task_name,exclude_beat_tasks,task_in_excluded_beat_tasks",
455+
[
456+
["some_task_name", ["xxx", "some_task.*"], True],
457+
["some_task_name", ["xxx", "some_other_task.*"], False],
458+
],
459+
)
460+
def test_exclude_redbeat_tasks_option(
461+
task_name, exclude_beat_tasks, task_in_excluded_beat_tasks
462+
):
463+
"""
464+
Test excluding Celery RedBeat tasks from automatic instrumentation.
465+
"""
466+
fake_maybe_due = MagicMock()
467+
468+
fake_redbeat_scheduler = MagicMock()
469+
fake_redbeat_scheduler.maybe_due = fake_maybe_due
470+
471+
fake_integration = MagicMock()
472+
fake_integration.exclude_beat_tasks = exclude_beat_tasks
473+
474+
fake_schedule_entry = MagicMock()
475+
fake_schedule_entry.name = task_name
476+
477+
fake_get_monitor_config = MagicMock()
478+
479+
with mock.patch(
480+
"sentry_sdk.integrations.celery.RedBeatScheduler", fake_redbeat_scheduler
481+
) as RedBeatScheduler: # noqa: N806
482+
with mock.patch(
483+
"sentry_sdk.integrations.celery.Hub.current.get_integration",
484+
return_value=fake_integration,
485+
):
486+
with mock.patch(
487+
"sentry_sdk.integrations.celery._get_monitor_config",
488+
fake_get_monitor_config,
489+
) as _get_monitor_config:
490+
# Mimic CeleryIntegration patching of RedBeatScheduler.maybe_due()
491+
_patch_redbeat_maybe_due()
492+
# Mimic Celery RedBeat calling a task from the RedBeat schedule
493+
RedBeatScheduler.maybe_due(fake_redbeat_scheduler, fake_schedule_entry)
494+
495+
if task_in_excluded_beat_tasks:
496+
# Only the original RedBeatScheduler.maybe_due() is called, _get_monitor_config is NOT called.
497+
assert fake_maybe_due.call_count == 1
498+
_get_monitor_config.assert_not_called()
499+
500+
else:
501+
# The original RedBeatScheduler.maybe_due() is called, AND _get_monitor_config is called.
502+
assert fake_maybe_due.call_count == 1
503+
assert _get_monitor_config.call_count == 1

0 commit comments

Comments
 (0)