Repository: getsentry/sentry-python

Issue:
Crons django-celery-beat timezone glitch
### How do you use Sentry?

Sentry Saas (sentry.io)

### Version

1.29.2

### Steps to Reproduce

1. Setup django-celery-beat
2. Create a Periodic task via the admin and set a wacked timezone for it
3. setup Sentry with CeleryIntegration(monitor_beat_tasks=True),
4. See the resulting cron monitor in sentry

The current implementation of the `_get_monitor_config` helper does not consider for the posibility that the schedule might be coming from django_celery_beat (this is understandable since this is all new).
Here is the implementation for it
https://django-celery-beat.readthedocs.io/en/latest/_modules/django_celery_beat/tzcrontab.html#TzAwareCrontab
which has a timezone property. The resulting schedule could have a different timezone from app.conf.timezone and could be per-schedule.

We traced it to here https://github.com/getsentry/sentry-python/blob/master/sentry_sdk/integrations/celery.py#L447 and think that considering that schedule could be an instance of TzAwareCrontab would be the way to go.

Not sure how you would like to code the possible import failure of django_celery_beat and so on, so stopped there.

### Expected Result

Expectation is that the cron monitor in sentry has the Americas/Chicago timezone we have setup in django admin for our task

### Actual Result

The task cron monitor in sentry has Americas/Los_Angeles as timezone as that is the app.conf.timezone


Discussion:
Adding a screenshot for clarity just in case 
<img width="1160" alt="image" src="https://github.com/getsentry/sentry-python/assets/2209022/4b27f8b6-6848-4a45-8b1e-d3732b40bf19">

Hey @avilaton !
Thanks for reporting this. I put it on our backlog. 
@antonpirker I've seen a number of reports about this actually. Let's see if we can't get this more heavily prioritized?
@evanpurkhiser I can take a look at this next week.
@evanpurkhiser the list of timezones in `django-celery-beat` has values in it, that are not in Sentry's list of timezones:
[timezones-django-celery-beat.txt](https://github.com/getsentry/sentry-python/files/13309280/timezones-django-celery-beat.txt)

When I select a timezone that is not available in Sentry's list, then the timezone `--` shows up in Sentry's UI.
Is there a way we can map those non existing timezones?

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
@@ -444,7 +444,15 @@ def _get_monitor_config(celery_schedule, app, monitor_name):
     if schedule_unit is not None:
         monitor_config["schedule"]["unit"] = schedule_unit
 
-    monitor_config["timezone"] = app.conf.timezone or "UTC"
+    monitor_config["timezone"] = (
+        (
+            hasattr(celery_schedule, "tz")
+            and celery_schedule.tz is not None
+            and str(celery_schedule.tz)
+        )
+        or app.timezone
+        or "UTC"
+    )
 
     return monitor_config
 


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
@@ -1,3 +1,6 @@
+import datetime
+import sys
+
 import pytest
 
 from sentry_sdk.integrations.celery import (
@@ -207,25 +210,65 @@ def test_crons_task_retry():
 
 def test_get_monitor_config_crontab():
     app = MagicMock()
-    app.conf = MagicMock()
-    app.conf.timezone = "Europe/Vienna"
+    app.timezone = "Europe/Vienna"
 
+    # schedule with the default timezone
     celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10")
+
     monitor_config = _get_monitor_config(celery_schedule, app, "foo")
     assert monitor_config == {
         "schedule": {
             "type": "crontab",
             "value": "*/10 12 3 * *",
         },
-        "timezone": "Europe/Vienna",
+        "timezone": "UTC",  # the default because `crontab` does not know about the app
     }
     assert "unit" not in monitor_config["schedule"]
 
+    # schedule with the timezone from the app
+    celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10", app=app)
+
+    monitor_config = _get_monitor_config(celery_schedule, app, "foo")
+    assert monitor_config == {
+        "schedule": {
+            "type": "crontab",
+            "value": "*/10 12 3 * *",
+        },
+        "timezone": "Europe/Vienna",  # the timezone from the app
+    }
+
+    # schedule without a timezone, the celery integration will read the config from the app
+    celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10")
+    celery_schedule.tz = None
+
+    monitor_config = _get_monitor_config(celery_schedule, app, "foo")
+    assert monitor_config == {
+        "schedule": {
+            "type": "crontab",
+            "value": "*/10 12 3 * *",
+        },
+        "timezone": "Europe/Vienna",  # the timezone from the app
+    }
+
+    # schedule without a timezone, and an app without timezone, the celery integration will fall back to UTC
+    app = MagicMock()
+    app.timezone = None
+
+    celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10")
+    celery_schedule.tz = None
+    monitor_config = _get_monitor_config(celery_schedule, app, "foo")
+    assert monitor_config == {
+        "schedule": {
+            "type": "crontab",
+            "value": "*/10 12 3 * *",
+        },
+        "timezone": "UTC",  # default timezone from celery integration
+    }
+
 
 def test_get_monitor_config_seconds():
     app = MagicMock()
-    app.conf = MagicMock()
-    app.conf.timezone = "Europe/Vienna"
+    app.timezone = "Europe/Vienna"
 
     celery_schedule = schedule(run_every=3)  # seconds
 
@@ -243,10 +286,55 @@ def test_get_monitor_config_seconds():
 
 def test_get_monitor_config_minutes():
     app = MagicMock()
-    app.conf = MagicMock()
-    app.conf.timezone = "Europe/Vienna"
+    app.timezone = "Europe/Vienna"
+
+    # schedule with the default timezone
+    celery_schedule = schedule(run_every=60)  # seconds
+
+    monitor_config = _get_monitor_config(celery_schedule, app, "foo")
+    assert monitor_config == {
+        "schedule": {
+            "type": "interval",
+            "value": 1,
+            "unit": "minute",
+        },
+        "timezone": "UTC",
+    }
+
+    # schedule with the timezone from the app
+    celery_schedule = schedule(run_every=60, app=app)  # seconds
+
+    monitor_config = _get_monitor_config(celery_schedule, app, "foo")
+    assert monitor_config == {
+        "schedule": {
+            "type": "interval",
+            "value": 1,
+            "unit": "minute",
+        },
+        "timezone": "Europe/Vienna",  # the timezone from the app
+    }
+
+    # schedule without a timezone, the celery integration will read the config from the app
+    celery_schedule = schedule(run_every=60)  # seconds
+    celery_schedule.tz = None
+
+    monitor_config = _get_monitor_config(celery_schedule, app, "foo")
+    assert monitor_config == {
+        "schedule": {
+            "type": "interval",
+            "value": 1,
+            "unit": "minute",
+        },
+        "timezone": "Europe/Vienna",  # the timezone from the app
+    }
+
+    # schedule without a timezone, and an app without timezone, the celery integration will fall back to UTC
+    app = MagicMock()
+    app.timezone = None
 
     celery_schedule = schedule(run_every=60)  # seconds
+    celery_schedule.tz = None
+
     monitor_config = _get_monitor_config(celery_schedule, app, "foo")
     assert monitor_config == {
         "schedule": {
@@ -254,14 +342,13 @@ def test_get_monitor_config_minutes():
             "value": 1,
             "unit": "minute",
         },
-        "timezone": "Europe/Vienna",
+        "timezone": "UTC",  # default timezone from celery integration
     }
 
 
 def test_get_monitor_config_unknown():
     app = MagicMock()
-    app.conf = MagicMock()
-    app.conf.timezone = "Europe/Vienna"
+    app.timezone = "Europe/Vienna"
 
     unknown_celery_schedule = MagicMock()
     monitor_config = _get_monitor_config(unknown_celery_schedule, app, "foo")
@@ -270,16 +357,45 @@ def test_get_monitor_config_unknown():
 
 def test_get_monitor_config_default_timezone():
     app = MagicMock()
-    app.conf = MagicMock()
-    app.conf.timezone = None
+    app.timezone = None
 
     celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10")
 
-    monitor_config = _get_monitor_config(celery_schedule, app, "foo")
+    monitor_config = _get_monitor_config(celery_schedule, app, "dummy_monitor_name")
 
     assert monitor_config["timezone"] == "UTC"
 
 
+def test_get_monitor_config_timezone_in_app_conf():
+    app = MagicMock()
+    app.timezone = "Asia/Karachi"
+
+    celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10")
+    celery_schedule.tz = None
+
+    monitor_config = _get_monitor_config(celery_schedule, app, "dummy_monitor_name")
+
+    assert monitor_config["timezone"] == "Asia/Karachi"
+
+
+@pytest.mark.skipif(
+    sys.version_info < (3, 0),
+    reason="no datetime.timezone for Python 2, so skipping this test.",
+)
+def test_get_monitor_config_timezone_in_celery_schedule():
+    app = MagicMock()
+    app.timezone = "Asia/Karachi"
+
+    panama_tz = datetime.timezone(datetime.timedelta(hours=-5), name="America/Panama")
+
+    celery_schedule = crontab(day_of_month="3", hour="12", minute="*/10")
+    celery_schedule.tz = panama_tz
+
+    monitor_config = _get_monitor_config(celery_schedule, app, "dummy_monitor_name")
+
+    assert monitor_config["timezone"] == str(panama_tz)
+
+
 @pytest.mark.parametrize(
     "task_name,exclude_beat_tasks,task_in_excluded_beat_tasks",
     [