Repository: getsentry/sentry-python

Issue:
Automatically create monitors when using Redbeat
### Environment

SaaS (https://sentry.io/)

### Steps to Reproduce

We're using Python with [Celery] and [Redbeat] and following the [docs] for setup. Using _either_ the autodiscover or manual `@monitor` instrumentation outlined in the docs, we are not having monitors in our Sentry UI automatically created. But manually creating them confirms that the data does come into Sentry—as soon as they're created we can see data for them in the UI, but only from that point forward.

I've been talking to Sentry support about this and was asked to create an Issue. Unfortunately our repo is private but our setup is pretty straightforward. Here's the gist of what we've got:

`requirements.txt`:

```
celery==5.2.6
celery-redbeat==2.0.0
```

`worker.py`

```py
from celery import Celery
from celery.signals import celeryd_init
from celery.schedules import crontab
import sentry_sdk
from sentry_sdk.crons.decorator import monitor
from sentry_sdk.integrations.celery import CeleryIntegration

from myco import _my_celery_task, get_env_var, LockInUse, get_redis_url

app = Celery(__name__, broker=get_redis_url(database='celery'))

@celeryd_init.connect
def init_sentry(**_kwargs):
    env = get_env_var('APP_ENV', False)
    if not env:
        print('APP_ENV not set, defaulting to development')
        env = 'development'
    if env not in ['development', 'test']:
        sentry_sdk.init(
            dsn="<DSN@ingest.sentry.io>", # obviously this is redacted

            environment=env,
            release=get_env_var('SENTRY_RELEASE_VERSION', False) or 'unknown',

            ignore_errors=[LockInUse],

            integrations=[CeleryIntegration()],

            # Set traces_sample_rate to 1.0 to capture 100%
            # of transactions for performance monitoring.
            # We recommend adjusting this value in production,
            traces_sample_rate=0.005,
        )

# ...


@app.task
@monitor(monitor_slug='my_celery_task')
def my_celery_task():
    return _my_celery_task()

# ...

app.conf.redbeat_redis_url = get_redis_url(database='celery')
app.conf.beat_scheduler = 'redbeat.RedBeatScheduler'
app.conf.beat_schedule = {
    'my-celery-task': {
        'task': 'worker.my_celery_task',
        'schedule': crontab(minute='0', hour='0', day_of_week='1'),
    },
    # ...
}
```

[celery]: https://docs.celeryq.dev/en/stable/
[redbeat]: https://pypi.org/project/celery-redbeat/
[docs]: https://docs.sentry.io/platforms/python/crons/

### Expected Result

Cron job monitors are automatically created in Sentry on deploy, or at least when they execute

### Actual Result

No monitors are created

### Product Area

Crons

### Link

_No response_

### DSN

_No response_

### Version

_No response_


Discussion:
Assigning to @getsentry/support for [routing](https://open.sentry.io/triage/#2-route) ⏲️
Routing to @getsentry/product-owners-crons for [triage](https://develop.sentry.dev/processing-tickets/#3-triage) ⏲️
@antonpirker could you take a look into this please when you're back?
Hi! Just adding my observations because I have been directed to this ticket by support.

I noticed a similar problem - I am using cron monitors for multiple automations that don't use celery. I used to have celery and django integrations enabled by default (because of historical reasons that don't make sense anymore) and decided to disable them for my scripts. It resulted in a better performance on the cron monitors' side. 

See the screenshot below:
<img width="1308" alt="image" src="https://github.com/getsentry/sentry/assets/5693175/9fa04811-2236-4668-ab69-9ba26f5d5023">

The gray line is at the point when the integrations were disabled.

As you can see - it significantly improved the stability of crons, but didn't fix the issue entirely. Is there anything else in addition to celery that I should watch out for? 

The version of sentry sdk for python that I use is `1.39.1` 
@IevgeniiB please create a issue in https://github.com/getsentry/sentry-python/ because it is something different the this issue. Thanks!

@bensaufley looks like task creation does not work when using redbeat. I will have a look.

What I know now:

For Celery beat we patch `celery.Beat.Scheduler.apply_entry` to add the `monitor-config` to check-ins. In Redbeats scheduler this function is not used, but instead this: https://github.com/sibson/redbeat/blob/v2.0.0/redbeat/schedulers.py#L450

So we need to patch this function to make it work when Redbeats scheduler is used.

> @IevgeniiB please create a issue in https://github.com/getsentry/sentry-python/ because it is something different the this issue. Thanks!

@antonpirker thank you! I've submitted a new issue with more info here: https://github.com/getsentry/sentry-python/issues/2617
Ok, we will add support for Redbeat. When it will be done, this issue will be updated of course. 

Resolving patch:
diff --git a/sentry_sdk/integrations/celery.py b/sentry_sdk/integrations/celery.py
--- a/sentry_sdk/integrations/celery.py
+++ b/sentry_sdk/integrations/celery.py
@@ -56,6 +56,11 @@
 except ImportError:
     raise DidNotEnable("Celery not installed")
 
+try:
+    from redbeat.schedulers import RedBeatScheduler  # type: ignore
+except ImportError:
+    RedBeatScheduler = None
+
 
 CELERY_CONTROL_FLOW_EXCEPTIONS = (Retry, Ignore, Reject)
 
@@ -76,6 +81,7 @@ def __init__(
 
         if monitor_beat_tasks:
             _patch_beat_apply_entry()
+            _patch_redbeat_maybe_due()
             _setup_celery_beat_signals()
 
     @staticmethod
@@ -535,6 +541,62 @@ def sentry_apply_entry(*args, **kwargs):
     Scheduler.apply_entry = sentry_apply_entry
 
 
+def _patch_redbeat_maybe_due():
+    # type: () -> None
+
+    if RedBeatScheduler is None:
+        return
+
+    original_maybe_due = RedBeatScheduler.maybe_due
+
+    def sentry_maybe_due(*args, **kwargs):
+        # type: (*Any, **Any) -> None
+        scheduler, schedule_entry = args
+        app = scheduler.app
+
+        celery_schedule = schedule_entry.schedule
+        monitor_name = schedule_entry.name
+
+        hub = Hub.current
+        integration = hub.get_integration(CeleryIntegration)
+        if integration is None:
+            return original_maybe_due(*args, **kwargs)
+
+        if match_regex_list(monitor_name, integration.exclude_beat_tasks):
+            return original_maybe_due(*args, **kwargs)
+
+        with hub.configure_scope() as scope:
+            # When tasks are started from Celery Beat, make sure each task has its own trace.
+            scope.set_new_propagation_context()
+
+            monitor_config = _get_monitor_config(celery_schedule, app, monitor_name)
+
+            is_supported_schedule = bool(monitor_config)
+            if is_supported_schedule:
+                headers = schedule_entry.options.pop("headers", {})
+                headers.update(
+                    {
+                        "sentry-monitor-slug": monitor_name,
+                        "sentry-monitor-config": monitor_config,
+                    }
+                )
+
+                check_in_id = capture_checkin(
+                    monitor_slug=monitor_name,
+                    monitor_config=monitor_config,
+                    status=MonitorStatus.IN_PROGRESS,
+                )
+                headers.update({"sentry-monitor-check-in-id": check_in_id})
+
+                # Set the Sentry configuration in the options of the ScheduleEntry.
+                # Those will be picked up in `apply_async` and added to the headers.
+                schedule_entry.options["headers"] = headers
+
+            return original_maybe_due(*args, **kwargs)
+
+    RedBeatScheduler.maybe_due = sentry_maybe_due
+
+
 def _setup_celery_beat_signals():
     # type: () -> None
     task_success.connect(crons_task_success)
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -50,6 +50,7 @@ def get_file_text(file_name):
         "beam": ["apache-beam>=2.12"],
         "bottle": ["bottle>=0.12.13"],
         "celery": ["celery>=3"],
+        "celery-redbeat": ["celery-redbeat>=2"],
         "chalice": ["chalice>=1.16.0"],
         "clickhouse-driver": ["clickhouse-driver>=0.2.0"],
         "django": ["django>=1.8"],


Test patch:
diff --git a/tests/integrations/celery/test_celery_beat_crons.py b/tests/integrations/celery/test_celery_beat_crons.py
--- a/tests/integrations/celery/test_celery_beat_crons.py
+++ b/tests/integrations/celery/test_celery_beat_crons.py
@@ -8,6 +8,7 @@
     _get_humanized_interval,
     _get_monitor_config,
     _patch_beat_apply_entry,
+    _patch_redbeat_maybe_due,
     crons_task_success,
     crons_task_failure,
     crons_task_retry,
@@ -447,3 +448,56 @@ def test_exclude_beat_tasks_option(
                     # The original Scheduler.apply_entry() is called, AND _get_monitor_config is called.
                     assert fake_apply_entry.call_count == 1
                     assert _get_monitor_config.call_count == 1
+
+
+@pytest.mark.parametrize(
+    "task_name,exclude_beat_tasks,task_in_excluded_beat_tasks",
+    [
+        ["some_task_name", ["xxx", "some_task.*"], True],
+        ["some_task_name", ["xxx", "some_other_task.*"], False],
+    ],
+)
+def test_exclude_redbeat_tasks_option(
+    task_name, exclude_beat_tasks, task_in_excluded_beat_tasks
+):
+    """
+    Test excluding Celery RedBeat tasks from automatic instrumentation.
+    """
+    fake_maybe_due = MagicMock()
+
+    fake_redbeat_scheduler = MagicMock()
+    fake_redbeat_scheduler.maybe_due = fake_maybe_due
+
+    fake_integration = MagicMock()
+    fake_integration.exclude_beat_tasks = exclude_beat_tasks
+
+    fake_schedule_entry = MagicMock()
+    fake_schedule_entry.name = task_name
+
+    fake_get_monitor_config = MagicMock()
+
+    with mock.patch(
+        "sentry_sdk.integrations.celery.RedBeatScheduler", fake_redbeat_scheduler
+    ) as RedBeatScheduler:  # noqa: N806
+        with mock.patch(
+            "sentry_sdk.integrations.celery.Hub.current.get_integration",
+            return_value=fake_integration,
+        ):
+            with mock.patch(
+                "sentry_sdk.integrations.celery._get_monitor_config",
+                fake_get_monitor_config,
+            ) as _get_monitor_config:
+                # Mimic CeleryIntegration patching of RedBeatScheduler.maybe_due()
+                _patch_redbeat_maybe_due()
+                # Mimic Celery RedBeat calling a task from the RedBeat schedule
+                RedBeatScheduler.maybe_due(fake_redbeat_scheduler, fake_schedule_entry)
+
+                if task_in_excluded_beat_tasks:
+                    # Only the original RedBeatScheduler.maybe_due() is called, _get_monitor_config is NOT called.
+                    assert fake_maybe_due.call_count == 1
+                    _get_monitor_config.assert_not_called()
+
+                else:
+                    # The original RedBeatScheduler.maybe_due() is called, AND _get_monitor_config is called.
+                    assert fake_maybe_due.call_count == 1
+                    assert _get_monitor_config.call_count == 1