Skip to content

Commit 4e19592

Browse files
wedamijaclaude
andauthored
feat(rq): Add an option to monitor rq-scheduler jobs (#8053)
Part of a series adding automatic Crons monitoring for the most-used job schedulers in each SDK. Check-ins for a monitor that doesn't exist yet are dropped, so today each monitor has to be created by hand with its schedule copied from the code. Sending the job's schedule as the monitor config lets Sentry create the monitor and keep it in sync. This adds `monitor_cron_jobs` to `RqIntegration` for recurring jobs from rq-scheduler's `scheduler.cron(...)` and `scheduler.schedule(..., interval=...)`. Only the worker needs the option, and it sends the cron string or interval as the monitor config. The slug is the job `id`, and jobs without one share a monitor named after the function's import path. It's off by default. ```python sentry_sdk.init( integrations=[RqIntegration(monitor_cron_jobs=True, exclude_cron_jobs=["cleanup"])], ) ``` Jobs aren't monitored when Sentry may read the cron string differently (6 fields, `@daily`, `MON`, both day fields set), the interval isn't whole minutes, the job has `repeat`, or the worker's local timezone isn't UTC. RQ's built-in `rq cron` scheduler isn't covered. The rq test envs now install `rq-scheduler` for RQ 2.x. Docs: getsentry/sentry-docs#20058 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f8b08b3 commit 4e19592

6 files changed

Lines changed: 526 additions & 57 deletions

File tree

‎scripts/populate_tox/config.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,7 @@
471471
# The default "addr" value was removed in https://github.com/cunla/fakeredis-py/commit/0441288fb22c8c191fc716b561e0001cf512abe5.
472472
# from fakeredis.
473473
">=1.1.14": ["fakeredis<2.36.0"],
474+
">=2.0": ["rq-scheduler"],
474475
"<0.9": ["fakeredis<1.0", "redis<3.2.2"],
475476
">=0.9,<0.14": ["fakeredis>=1.0,<1.7.4"],
476477
"py3.6,py3.7": ["fakeredis!=2.26.0"],

‎scripts/populate_tox/package_dependencies.jsonl‎

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎sentry_sdk/crons/utils.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from typing import TYPE_CHECKING
2+
3+
if TYPE_CHECKING:
4+
from typing import Optional, Tuple
5+
6+
from sentry_sdk._types import MonitorConfigSchedule, MonitorConfigScheduleUnit
7+
8+
9+
_INTERVAL_UNITS: "Tuple[Tuple[MonitorConfigScheduleUnit, int], ...]" = (
10+
("day", 60 * 60 * 24),
11+
("hour", 60 * 60),
12+
("minute", 60),
13+
)
14+
15+
16+
def _get_interval_schedule(seconds: float) -> "Optional[MonitorConfigSchedule]":
17+
"""
18+
Express an interval in the largest unit that divides it evenly.
19+
20+
Returns None if the interval isn't a whole number of minutes.
21+
"""
22+
for unit, unit_seconds in _INTERVAL_UNITS:
23+
if seconds >= unit_seconds and seconds % unit_seconds == 0:
24+
return {
25+
"type": "interval",
26+
"value": int(seconds // unit_seconds),
27+
"unit": unit,
28+
}
29+
return None

‎sentry_sdk/integrations/rq.py‎

Lines changed: 189 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import functools
2+
import re
3+
import time
24
import weakref
35

46
import sentry_sdk
57
from sentry_sdk.api import continue_trace
68
from sentry_sdk.consts import OP, SPANDATA
9+
from sentry_sdk.crons import MonitorStatus, capture_checkin
10+
from sentry_sdk.crons.utils import _get_interval_schedule
711
from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version
812
from sentry_sdk.integrations.logging import ignore_logger
913
from sentry_sdk.scope import Scope, should_send_default_pii
@@ -16,6 +20,9 @@
1620
event_from_exception,
1721
format_timestamp,
1822
has_data_collection_enabled,
23+
logger,
24+
match_regex_list,
25+
now,
1926
parse_version,
2027
)
2128

@@ -39,18 +46,33 @@
3946
from typing import TYPE_CHECKING
4047

4148
if TYPE_CHECKING:
42-
from typing import Any, Callable
49+
from typing import Any, Callable, List, Optional, Tuple
4350

4451
from rq.job import Job
4552

46-
from sentry_sdk._types import Event, EventProcessor
53+
from sentry_sdk._types import Event, EventProcessor, MonitorConfig
4754
from sentry_sdk.utils import ExcInfo
4855

56+
# rq generates `str(uuid4())` job ids.
57+
_GENERATED_JOB_ID = re.compile(
58+
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
59+
)
60+
# `*`, `*/n`, `n`, `n/n`, `n-n` and `n-n/n`.
61+
_CRONTAB_ITEM = re.compile(r"\*(?:/\d+)?|(\d+)(?:-(\d+)(?:/\d+)?|/\d+)?")
62+
4963

5064
class RqIntegration(Integration):
5165
identifier = "rq"
5266
origin = f"auto.queue.{identifier}"
5367

68+
def __init__(
69+
self,
70+
monitor_cron_jobs: bool = False,
71+
exclude_cron_jobs: "Optional[List[str]]" = None,
72+
) -> None:
73+
self.monitor_cron_jobs = monitor_cron_jobs
74+
self.exclude_cron_jobs = exclude_cron_jobs
75+
5476
@staticmethod
5577
def setup_once() -> None:
5678
version = parse_version(RQ_VERSION)
@@ -69,64 +91,87 @@ def sentry_patched_perform_job(
6991
self: "Any", job: "Job", queue: "Queue", *args: "Any", **kwargs: "Any"
7092
) -> bool:
7193
client = sentry_sdk.get_client()
72-
if client.get_integration(RqIntegration) is None:
94+
integration = client.get_integration(RqIntegration)
95+
if integration is None:
7396
return old_perform_job(self, job, queue, *args, **kwargs)
7497

75-
with sentry_sdk.new_scope() as scope:
76-
scope.clear_breadcrumbs()
77-
scope.add_event_processor(_make_event_processor(weakref.ref(job)))
78-
79-
if has_span_streaming_enabled(client.options):
80-
sentry_sdk.traces.continue_trace(
81-
job.meta.get("_sentry_trace_headers") or {}
82-
)
83-
84-
Scope.set_custom_sampling_context({"rq_job": job})
85-
86-
func_name = None
87-
with capture_internal_exceptions():
88-
func_name = job.func_name
89-
90-
with sentry_sdk.traces.start_span(
91-
name="unknown RQ task" if func_name is None else func_name,
92-
attributes={
93-
"sentry.op": OP.QUEUE_TASK_RQ,
94-
"sentry.origin": RqIntegration.origin,
95-
"sentry.segment.name.source": SegmentNameSource.TASK,
96-
SPANDATA.MESSAGING_MESSAGE_ID: job.id,
97-
SPANDATA.MESSAGING_DESTINATION_NAME: queue.name,
98-
},
99-
parent_span=None,
100-
) as span:
101-
if func_name is not None:
102-
span.set_attribute(SPANDATA.CODE_FUNCTION_NAME, func_name)
103-
104-
rv = old_perform_job(self, job, queue, *args, **kwargs)
105-
else:
106-
transaction = continue_trace(
107-
job.meta.get("_sentry_trace_headers") or {},
108-
op=OP.QUEUE_TASK_RQ,
109-
name="unknown RQ task",
110-
source=TransactionSource.TASK,
111-
origin=RqIntegration.origin,
112-
)
113-
98+
check_in = None
99+
with capture_internal_exceptions():
100+
check_in = _start_check_in(integration, job)
101+
102+
status = MonitorStatus.ERROR
103+
try:
104+
with sentry_sdk.new_scope() as scope:
105+
scope.clear_breadcrumbs()
106+
scope.add_event_processor(_make_event_processor(weakref.ref(job)))
107+
108+
if has_span_streaming_enabled(client.options):
109+
sentry_sdk.traces.continue_trace(
110+
job.meta.get("_sentry_trace_headers") or {}
111+
)
112+
113+
Scope.set_custom_sampling_context({"rq_job": job})
114+
115+
func_name = None
116+
with capture_internal_exceptions():
117+
func_name = job.func_name
118+
119+
with sentry_sdk.traces.start_span(
120+
name="unknown RQ task" if func_name is None else func_name,
121+
attributes={
122+
"sentry.op": OP.QUEUE_TASK_RQ,
123+
"sentry.origin": RqIntegration.origin,
124+
"sentry.segment.name.source": SegmentNameSource.TASK,
125+
SPANDATA.MESSAGING_MESSAGE_ID: job.id,
126+
SPANDATA.MESSAGING_DESTINATION_NAME: queue.name,
127+
},
128+
parent_span=None,
129+
) as span:
130+
if func_name is not None:
131+
span.set_attribute(
132+
SPANDATA.CODE_FUNCTION_NAME, func_name
133+
)
134+
135+
rv = old_perform_job(self, job, queue, *args, **kwargs)
136+
else:
137+
transaction = continue_trace(
138+
job.meta.get("_sentry_trace_headers") or {},
139+
op=OP.QUEUE_TASK_RQ,
140+
name="unknown RQ task",
141+
source=TransactionSource.TASK,
142+
origin=RqIntegration.origin,
143+
)
144+
145+
with capture_internal_exceptions():
146+
transaction.name = job.func_name
147+
148+
with sentry_sdk.start_transaction(
149+
transaction,
150+
custom_sampling_context={"rq_job": job},
151+
) as span:
152+
span.set_data(
153+
SPANDATA.MESSAGING_DESTINATION_NAME, queue.name
154+
)
155+
156+
rv = old_perform_job(self, job, queue, *args, **kwargs)
157+
status = MonitorStatus.OK if rv else MonitorStatus.ERROR
158+
finally:
159+
if check_in is not None:
114160
with capture_internal_exceptions():
115-
transaction.name = job.func_name
116-
117-
with sentry_sdk.start_transaction(
118-
transaction,
119-
custom_sampling_context={"rq_job": job},
120-
) as span:
121-
span.set_data(SPANDATA.MESSAGING_DESTINATION_NAME, queue.name)
122-
123-
rv = old_perform_job(self, job, queue, *args, **kwargs)
124-
125-
if self.is_horse:
126-
# We're inside of a forked process and RQ is
127-
# about to call `os._exit`. Make sure that our
128-
# events get sent out.
129-
sentry_sdk.get_client().flush()
161+
monitor_slug, monitor_config, check_in_id, start = check_in
162+
capture_checkin(
163+
monitor_slug=monitor_slug,
164+
monitor_config=monitor_config,
165+
check_in_id=check_in_id,
166+
duration=now() - start,
167+
status=status,
168+
)
169+
170+
if self.is_horse:
171+
# We're inside of a forked process and RQ is
172+
# about to call `os._exit`. Make sure that our
173+
# events get sent out.
174+
sentry_sdk.get_client().flush()
130175

131176
return rv
132177

@@ -229,3 +274,90 @@ def _capture_exception(exc_info: "ExcInfo", **kwargs: "Any") -> None:
229274
)
230275

231276
sentry_sdk.capture_event(event, hint=hint)
277+
278+
279+
def _is_portable_crontab_field(field: str, is_weekday: bool) -> bool:
280+
for item in field.split(","):
281+
match = _CRONTAB_ITEM.fullmatch(item)
282+
if match is None:
283+
return False
284+
first, last = match.groups()
285+
# rq-scheduler's crontab parser and Sentry read these differently.
286+
if last is not None and int(last) <= int(first):
287+
return False
288+
if is_weekday and first is not None and last is None and "/" in item:
289+
return False
290+
return True
291+
292+
293+
def _get_crontab_config(cron_string: str) -> "Optional[MonitorConfig]":
294+
fields = cron_string.split()
295+
if len(fields) != 5 or not all(
296+
_is_portable_crontab_field(field, i == 4) for i, field in enumerate(fields)
297+
):
298+
return None
299+
# Day of month and day of week are matched differently by different
300+
# cron parsers when both are set.
301+
if fields[2] != "*" and fields[4] != "*":
302+
return None
303+
# rq-scheduler evaluates crontabs in the system's local time.
304+
if time.timezone != 0 or (time.daylight and time.altzone != 0):
305+
return None
306+
return {
307+
"schedule": {"type": "crontab", "value": " ".join(fields)},
308+
"timezone": "UTC",
309+
}
310+
311+
312+
def _get_monitor_config(job: "Job") -> "Optional[MonitorConfig]":
313+
# Jobs with `repeat` stop after a number of runs.
314+
if job.meta.get("repeat") is not None:
315+
return None
316+
317+
cron_string = job.meta.get("cron_string")
318+
if isinstance(cron_string, str):
319+
return _get_crontab_config(cron_string)
320+
321+
interval = job.meta.get("interval")
322+
if isinstance(interval, int):
323+
schedule = _get_interval_schedule(interval)
324+
if schedule is not None:
325+
return {"schedule": schedule}
326+
return None
327+
328+
329+
def _start_check_in(
330+
integration: "RqIntegration", job: "Job"
331+
) -> "Optional[Tuple[str, MonitorConfig, Optional[str], float]]":
332+
"""
333+
Send an `in_progress` check-in for jobs that rq-scheduler's `cron()` and
334+
`schedule(interval=...)` re-enqueue. It stores their schedule in the meta.
335+
"""
336+
if not integration.monitor_cron_jobs:
337+
return None
338+
if "cron_string" not in job.meta and "interval" not in job.meta:
339+
return None
340+
341+
# Without an explicit `id`, every scheduled job gets a random one.
342+
if _GENERATED_JOB_ID.fullmatch(job.id):
343+
monitor_slug = job.func_name
344+
else:
345+
monitor_slug = job.id
346+
if match_regex_list(monitor_slug, integration.exclude_cron_jobs):
347+
return None
348+
349+
monitor_config = _get_monitor_config(job)
350+
if monitor_config is None:
351+
logger.debug(
352+
"Not monitoring RQ job %r: its schedule can't be expressed as a "
353+
"Sentry Crons schedule.",
354+
monitor_slug,
355+
)
356+
return None
357+
358+
check_in_id = capture_checkin(
359+
monitor_slug=monitor_slug,
360+
monitor_config=monitor_config,
361+
status=MonitorStatus.IN_PROGRESS,
362+
)
363+
return monitor_slug, monitor_config, check_in_id, now()

0 commit comments

Comments
 (0)