Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions sentry_sdk/crons/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]":
Expand All @@ -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
26 changes: 26 additions & 0 deletions sentry_sdk/crons/utils.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
25 changes: 9 additions & 16 deletions sentry_sdk/integrations/arq.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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:<function name>" by default.
monitor_slug = cron_job.name
if monitor_slug.startswith("cron:"):
Expand Down Expand Up @@ -393,35 +398,23 @@ 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.
if time.timezone == 0 and (not time.daylight or time.altzone == 0):
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 {
Expand Down
20 changes: 20 additions & 0 deletions tests/integrations/arq/test_arq_crons.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading