Skip to content
Draft
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
1 change: 1 addition & 0 deletions scripts/populate_tox/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,7 @@
# The default "addr" value was removed in https://github.com/cunla/fakeredis-py/commit/0441288fb22c8c191fc716b561e0001cf512abe5.
# from fakeredis.
">=1.1.14": ["fakeredis<2.36.0"],
">=2.0": ["rq-scheduler"],
"<0.9": ["fakeredis<1.0", "redis<3.2.2"],
">=0.9,<0.14": ["fakeredis>=1.0,<1.7.4"],
"py3.6,py3.7": ["fakeredis!=2.26.0"],
Expand Down
4 changes: 4 additions & 0 deletions scripts/populate_tox/package_dependencies.jsonl

Large diffs are not rendered by default.

144 changes: 141 additions & 3 deletions sentry_sdk/integrations/rq.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import functools
import re
import time
import weakref

import sentry_sdk
from sentry_sdk.api import continue_trace
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.crons import MonitorStatus, capture_checkin
from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version
from sentry_sdk.integrations.logging import ignore_logger
from sentry_sdk.scope import Scope, should_send_default_pii
Expand All @@ -16,6 +19,9 @@
event_from_exception,
format_timestamp,
has_data_collection_enabled,
logger,
match_regex_list,
now,
parse_version,
)

Expand All @@ -39,18 +45,31 @@
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from typing import Any, Callable
from typing import Any, Callable, List, Optional, Set, Tuple

from rq.job import Job

from sentry_sdk._types import Event, EventProcessor
from sentry_sdk._types import (
Event,
EventProcessor,
MonitorConfig,
MonitorConfigScheduleUnit,
)
from sentry_sdk.utils import ExcInfo


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

def __init__(
self,
monitor_cron_jobs: bool = False,
exclude_cron_jobs: "Optional[List[str]]" = None,
) -> None:
self.monitor_cron_jobs = monitor_cron_jobs
self.exclude_cron_jobs = exclude_cron_jobs

@staticmethod
def setup_once() -> None:
version = parse_version(RQ_VERSION)
Expand All @@ -69,9 +88,14 @@
self: "Any", job: "Job", queue: "Queue", *args: "Any", **kwargs: "Any"
) -> bool:
client = sentry_sdk.get_client()
if client.get_integration(RqIntegration) is None:
integration = client.get_integration(RqIntegration)
if integration is None:
return old_perform_job(self, job, queue, *args, **kwargs)

check_in = None
with capture_internal_exceptions():
check_in = _start_check_in(integration, job)

Check warning on line 97 in sentry_sdk/integrations/rq.py

View check run for this annotation

@sentry/warden / warden: code-review

Generated job IDs produce invalid monitor slugs

When a scheduled job has no explicit `id`, `_start_check_in` uses `job.func_name` as the monitor slug. RQ function names commonly include a dotted module path, as the generated-ID test demonstrates, but dots are not valid in Sentry monitor slugs (which are limited to 50 characters). The check-in is sent with that value unchanged, so the generated-ID fallback cannot create or update the intended monitor. Use a valid slug derived from the function name, or require an explicit ID.

Check warning on line 97 in sentry_sdk/integrations/rq.py

View check run for this annotation

@sentry/warden / warden: find-bugs

Crontab monitor timezone taken from worker, not scheduler

`_get_crontab_config` (via `_start_check_in`) treats the worker being UTC as proof the cron runs in UTC, so a non-UTC `rqscheduler` with UTC workers upserts the wrong monitor timezone and causes false missed check-ins.
Comment on lines +95 to +97

@sentry-warden sentry-warden Bot Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated-ID cron jobs use dotted func_name as monitor_slug

_start_check_in falls back to job.func_name (e.g. module.task) when the job ID is a UUID, but Sentry monitor slugs only allow [a-zA-Z0-9_-]; sanitize or replace dots so check-ins are not rejected.

Evidence
  • sentry_patched_perform_job calls _start_check_in(integration, job) for every job when the integration is enabled.
  • In _start_check_in, monitor_slug = job.func_name if _GENERATED_JOB_ID.fullmatch(job.id) else job.id.
  • RQ job.func_name is a dotted import path (confirmed by test_cron_job_generated_id expecting tests.integrations.rq.test_rq_crons.ok_job).
  • Sentry monitor slugs are validated as ^[a-zA-Z0-9_-]+- sentry_patched_perform_jobcalls_start_check_in(integration, job)` for every job when the integration is enabled.
  • In _start_check_in, monitor_slug = job.func_name if _GENERATED_JOB_ID.fullmatch(job.id) else job.id.
  • RQ job.func_name is a dotted import path (confirmed by test_cron_job_generated_id expecting tests.integrations.rq.test_rq_crons.ok_job).
  • Sentry monitor slugs are validated as , so these check-ins fail for the common scheduler.cron(..., func=task) path without an explicit id.

Identified by Warden · code-review, find-bugs · 8PJ-S34

Comment on lines +95 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated IDs collapse distinct schedules for the same function

When scheduled jobs have generated IDs, _start_check_in uses job.func_name as the monitor slug. Two recurring schedules for the same function therefore share one monitor, and each check-in sends its own schedule config to that monitor. The monitor cannot represent both schedules independently; its config is left reflecting whichever schedule was sent last.

Evidence
  • _start_check_in substitutes job.func_name when job.id matches _GENERATED_JOB_ID; test_cron_job_generated_id confirms the fallback is used for generated IDs.
  • The fallback does not include the job’s cron_string or interval, so separate schedules for the same function produce the same slug.
  • _get_monitor_config derives config from each job’s schedule metadata, and _start_check_in passes that config to capture_checkin.
  • The tests cover a single generated-ID schedule, but do not cover distinct schedules for the same function.

Identified by Warden · find-bugs · FW4-M52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix attempt detected (commit 6f68bc3)

The change warns that generated IDs for the same function share a monitor, but still uses the function name as the slug, so distinct schedules continue to collide unless callers provide explicit IDs.

The original issue appears unresolved. Please review and try again.

Evaluated by Warden

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crontab monitor timezone taken from worker, not scheduler

_get_crontab_config (via _start_check_in) treats the worker being UTC as proof the cron runs in UTC, so a non-UTC rqscheduler with UTC workers upserts the wrong monitor timezone and causes false missed check-ins.

Evidence
  • _start_check_in() → _get_monitor_config() → _get_crontab_config() builds monitor config on the worker only.
  • _get_crontab_config checks time.timezone / time.daylight / time.altzone in the worker process, then hard-codes "timezone": "UTC".
  • Comments and PR design state rq-scheduler evaluates crontabs in the scheduler process local time, and nothing runs in rqscheduler.
  • Tests only cover worker TZ non-UTC → no config; they never cover scheduler TZ ≠ worker TZ.

Identified by Warden · find-bugs · 4XW-77D


with sentry_sdk.new_scope() as scope:
scope.clear_breadcrumbs()
scope.add_event_processor(_make_event_processor(weakref.ref(job)))
Expand Down Expand Up @@ -122,6 +146,10 @@

rv = old_perform_job(self, job, queue, *args, **kwargs)

if check_in is not None:
with capture_internal_exceptions():
_finish_check_in(check_in, rv)

if self.is_horse:
# We're inside of a forked process and RQ is
# about to call `os._exit`. Make sure that our
Expand Down Expand Up @@ -229,3 +257,113 @@
)

sentry_sdk.capture_event(event, hint=hint)


# rq generates `str(uuid4())` job ids.
_GENERATED_JOB_ID = re.compile(
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
)
_warned_func_names: "Set[str]" = set()
_CRONTAB_FIELD = re.compile(r"[0-9*,/-]+")
_INTERVAL_UNITS: "Tuple[Tuple[MonitorConfigScheduleUnit, int], ...]" = (
("day", 60 * 60 * 24),
("hour", 60 * 60),
("minute", 60),
)


def _get_crontab_config(cron_string: str) -> "Optional[MonitorConfig]":
fields = cron_string.split()
if len(fields) != 5 or not all(_CRONTAB_FIELD.fullmatch(f) for f in fields):
return None
# Day of month and day of week are matched differently by different
# cron parsers when both are set.
if fields[2] != "*" and fields[4] != "*":
return None
# rq-scheduler evaluates crontabs in the system's local time.
if time.timezone != 0 or (time.daylight and time.altzone != 0):
return None
return {
"schedule": {"type": "crontab", "value": " ".join(fields)},
"timezone": "UTC",
}


def _get_monitor_config(job: "Job") -> "Optional[MonitorConfig]":
# Jobs with `repeat` stop after a number of runs.
if job.meta.get("repeat") is not None:
return None

cron_string = job.meta.get("cron_string")
if isinstance(cron_string, str):
return _get_crontab_config(cron_string)

interval = job.meta.get("interval")
if isinstance(interval, int):
for unit, seconds in _INTERVAL_UNITS:
if interval >= seconds and interval % seconds == 0:
return {
"schedule": {
"type": "interval",
"value": interval // seconds,
"unit": unit,
}
}
return None


def _start_check_in(
integration: "RqIntegration", job: "Job"
) -> "Optional[Tuple[str, Optional[MonitorConfig], Optional[str], float]]":
"""
Send an `in_progress` check-in for jobs that rq-scheduler's `cron()` and
`schedule(interval=...)` re-enqueue. It stores their schedule in the meta.
"""
if not integration.monitor_cron_jobs:
return None
if "cron_string" not in job.meta and "interval" not in job.meta:
return None

Check warning on line 325 in sentry_sdk/integrations/rq.py

View check run for this annotation

@sentry/warden / warden: find-bugs

One-off schedule() jobs are monitored when meta interval is None

Gate on recurring jobs with truthiness (`not job.meta.get("cron_string") and not job.meta.get("interval")`) instead of key presence—rq-scheduler always sets `meta['interval'] = interval`, so one-off `schedule()` jobs still get check-ins.
Comment on lines +324 to +325

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One-off schedule() jobs are monitored when meta interval is None

Gate on recurring jobs with truthiness (not job.meta.get("cron_string") and not job.meta.get("interval")) instead of key presence—rq-scheduler always sets meta['interval'] = interval, so one-off schedule() jobs still get check-ins.

Evidence
  • _start_check_in returns early only when both "cron_string" and "interval" are absent from job.meta.
  • rq-scheduler's schedule() always assigns job.meta['interval'] = interval, including when interval is None for a one-off run.
  • With the key present, execution continues to capture_checkin, so one-off schedule() jobs emit check-ins.
  • test_one_off_jobs_not_monitored expects no check-ins for scheduler.schedule(..., ok_job) without an interval.

Identified by Warden · find-bugs · WBR-F4V


# Without an explicit `id`, every scheduled job gets a random one.
if _GENERATED_JOB_ID.fullmatch(job.id):
monitor_slug = job.func_name
if monitor_slug not in _warned_func_names:
_warned_func_names.add(monitor_slug)
logger.warning(
"RQ scheduled job %r has no explicit `id`, so its function name "
"is used as its monitor slug, and jobs for the same function "
"share a monitor. Pass `id` to give it its own monitor.",
monitor_slug,

Check warning on line 336 in sentry_sdk/integrations/rq.py

View check run for this annotation

@sentry/warden / warden: find-bugs

Generated job IDs use dotted func_name as monitor slug

Sanitize or reject `job.func_name` before using it as `monitor_slug`—dotted module paths are invalid Sentry monitor slugs (`[a-zA-Z0-9_-]`, max 50), so the no-`id` fallback cannot create or update monitors.
)
else:
monitor_slug = job.id
if match_regex_list(monitor_slug, integration.exclude_cron_jobs):
return None

monitor_config = _get_monitor_config(job)
if monitor_config is None:
logger.debug(
"Schedule of RQ job %r can't be expressed as a Sentry Crons "
"schedule. Sending check-ins without a monitor config.",
monitor_slug,
)

check_in_id = capture_checkin(
monitor_slug=monitor_slug,
monitor_config=monitor_config,
status=MonitorStatus.IN_PROGRESS,
)
return monitor_slug, monitor_config, check_in_id, now()


def _finish_check_in(
check_in: "Tuple[str, Optional[MonitorConfig], Optional[str], float]", ok: bool
) -> None:
monitor_slug, monitor_config, check_in_id, start = check_in
capture_checkin(
monitor_slug=monitor_slug,
monitor_config=monitor_config,
check_in_id=check_in_id,
duration=now() - start,
status=MonitorStatus.OK if ok else MonitorStatus.ERROR,
)
Loading
Loading