diff --git a/sentry_sdk/crons/decorator.py b/sentry_sdk/crons/decorator.py index b13d350e15..fd4677709d 100644 --- a/sentry_sdk/crons/decorator.py +++ b/sentry_sdk/crons/decorator.py @@ -126,6 +126,7 @@ async def inner(*args: "P.args", **kwargs: "P.kwargs") -> "R": with self: return await fn(*args, **kwargs) + inner._sentry_monitored = True # type: ignore[attr-defined] return inner def _sync_wrapper(self, fn: "Callable[P, R]") -> "Callable[P, R]": @@ -134,4 +135,5 @@ def inner(*args: "P.args", **kwargs: "P.kwargs") -> "R": with self: return fn(*args, **kwargs) + inner._sentry_monitored = True # type: ignore[attr-defined] return inner diff --git a/sentry_sdk/crons/utils.py b/sentry_sdk/crons/utils.py index d5fa55e500..01a70c1c1b 100644 --- a/sentry_sdk/crons/utils.py +++ b/sentry_sdk/crons/utils.py @@ -1,6 +1,9 @@ +import re +from datetime import timedelta, timezone from typing import TYPE_CHECKING if TYPE_CHECKING: + from datetime import tzinfo from typing import Optional, Tuple from sentry_sdk._types import MonitorConfigSchedule, MonitorConfigScheduleUnit @@ -27,3 +30,26 @@ def _get_interval_schedule(seconds: float) -> "Optional[MonitorConfigSchedule]": "unit": unit, } return None + + +# IANA zone names start with an uppercase letter. This rejects names like +# "local" or "localtime" that tzlocal can give a zone. +_IANA_TIMEZONE_NAME = re.compile(r"[A-Z][A-Za-z0-9._+-]*(?:/[A-Za-z0-9._+-]+)*") + + +def _get_timezone_name(tz: "tzinfo") -> "Optional[str]": + """ + Return the IANA name of the timezone, or None if it has none. + """ + name = getattr(tz, "key", None) or getattr(tz, "zone", None) + if isinstance(name, str): + return name if _IANA_TIMEZONE_NAME.fullmatch(name) else None + + if isinstance(tz, timezone): + offset = tz.utcoffset(None) + if offset % timedelta(hours=1): + return None + hours = offset // timedelta(hours=1) + return "Etc/GMT%+d" % -hours if hours else "UTC" + + return None diff --git a/sentry_sdk/integrations/arq.py b/sentry_sdk/integrations/arq.py index 623dda3258..502fe0355d 100644 --- a/sentry_sdk/integrations/arq.py +++ b/sentry_sdk/integrations/arq.py @@ -1,10 +1,11 @@ import sys import time -from datetime import timedelta, timezone +from functools import wraps import sentry_sdk from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS from sentry_sdk.crons import monitor +from sentry_sdk.crons.utils import _get_timezone_name from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.scope import should_send_default_pii @@ -221,6 +222,7 @@ def event_processor(event: "Event", hint: "Hint") -> "Optional[Event]": def _wrap_coroutine(name: str, coroutine: "WorkerCoroutine") -> "WorkerCoroutine": + @wraps(coroutine) async def _sentry_coroutine( ctx: "Dict[Any, Any]", *args: "Any", **kwargs: "Any" ) -> "Any": @@ -326,6 +328,9 @@ def _monitor_cron_job( return cron_job._sentry_monitored = True + if getattr(cron_job.coroutine, "_sentry_monitored", False): + return + # arq names cron jobs "cron:" by default. monitor_slug = cron_job.name if monitor_slug.startswith("cron:"): @@ -393,7 +398,7 @@ def _get_crontab(cron_job: "CronJob") -> "Optional[str]": return " ".join(fields) # type: ignore[arg-type] -def _get_timezone_name(worker: "Worker") -> "Optional[str]": +def _get_worker_timezone_name(worker: "Worker") -> "Optional[str]": tz = getattr(worker, "timezone", None) if tz is None: # Before arq 0.25, cron jobs ran in naive local time. @@ -401,27 +406,15 @@ def _get_timezone_name(worker: "Worker") -> "Optional[str]": return "UTC" return None - # zoneinfo and pytz - name = getattr(tz, "key", None) or getattr(tz, "zone", None) - if isinstance(name, str): - return name - # arq defaults to the system's fixed UTC offset at startup. - if isinstance(tz, timezone): - offset = tz.utcoffset(None) - if offset % timedelta(hours=1): - return None - hours = offset // timedelta(hours=1) - return "Etc/GMT%+d" % -hours if hours else "UTC" - - return None + return _get_timezone_name(tz) def _get_monitor_config( cron_job: "CronJob", worker: "Worker" ) -> "Optional[MonitorConfig]": crontab = _get_crontab(cron_job) - timezone_name = _get_timezone_name(worker) + timezone_name = _get_worker_timezone_name(worker) if crontab is None or timezone_name is None: return None return { diff --git a/tests/integrations/arq/test_arq_crons.py b/tests/integrations/arq/test_arq_crons.py index 3087e6e594..88686e02b1 100644 --- a/tests/integrations/arq/test_arq_crons.py +++ b/tests/integrations/arq/test_arq_crons.py @@ -7,6 +7,7 @@ from arq.connections import ArqRedis from fakeredis.aioredis import FakeRedis +import sentry_sdk from sentry_sdk.integrations.arq import ArqIntegration, _get_monitor_config try: @@ -183,6 +184,25 @@ async def test_cron_job_monitored_once(sentry_init, capture_envelopes): assert statuses[:4] == ["in_progress", "ok", "in_progress", "ok"] +@sentry_sdk.monitor(monitor_slug="decorated") +async def decorated_job(ctx): + pass + + +@pytest.mark.asyncio +async def test_monitor_decorated_cron_job_reports_once(sentry_init, capture_envelopes): + sentry_init(integrations=[ArqIntegration(monitor_cron_jobs=True)]) + envelopes = capture_envelopes() + + await _run_cron_jobs( + cron(decorated_job, run_at_startup=True), timezone=timezone.utc + ) + + check_ins = _check_ins(envelopes) + assert {c["monitor_slug"] for c in check_ins} == {"decorated"} + assert [c["status"] for c in check_ins[:2]] == ["in_progress", "ok"] + + class _Worker: def __init__(self, timezone): self.timezone = timezone