11import functools
2+ import re
3+ import time
24import weakref
35
46import sentry_sdk
57from sentry_sdk .api import continue_trace
68from 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
711from sentry_sdk .integrations import DidNotEnable , Integration , _check_minimum_version
812from sentry_sdk .integrations .logging import ignore_logger
913from sentry_sdk .scope import Scope , should_send_default_pii
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
3946from typing import TYPE_CHECKING
4047
4148if 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
5064class 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