foxray 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- foxray/__init__.py +3 -0
- foxray/__main__.py +3 -0
- foxray/core/__init__.py +2 -0
- foxray/core/alerts.py +225 -0
- foxray/core/auth.py +54 -0
- foxray/core/cron.py +176 -0
- foxray/core/dsn.py +63 -0
- foxray/core/envelope.py +166 -0
- foxray/core/filters.py +173 -0
- foxray/core/fingerprint.py +82 -0
- foxray/core/ingest.py +255 -0
- foxray/core/maintenance.py +321 -0
- foxray/core/models.py +178 -0
- foxray/core/monitors.py +150 -0
- foxray/core/normalize.py +358 -0
- foxray/core/present.py +146 -0
- foxray/core/scrub.py +96 -0
- foxray/core/settings.py +96 -0
- foxray/core/sql.py +996 -0
- foxray/core/storage.py +139 -0
- foxray/core/util.py +101 -0
- foxray/django/__init__.py +4 -0
- foxray/django/admin.py +406 -0
- foxray/django/apps.py +21 -0
- foxray/django/conf.py +39 -0
- foxray/django/management/__init__.py +0 -0
- foxray/django/management/commands/__init__.py +0 -0
- foxray/django/management/commands/foxray_create_project.py +18 -0
- foxray/django/management/commands/foxray_prune.py +20 -0
- foxray/django/management/commands/foxray_tick.py +16 -0
- foxray/django/migrations/0001_initial.py +696 -0
- foxray/django/migrations/0002_default_project.py +18 -0
- foxray/django/migrations/__init__.py +0 -0
- foxray/django/models.py +275 -0
- foxray/django/runtime.py +162 -0
- foxray/django/sdk.py +231 -0
- foxray/django/store.py +48 -0
- foxray/django/templates/admin/foxray/_style.html +21 -0
- foxray/django/templates/admin/foxray/issue_detail.html +49 -0
- foxray/django/templates/admin/foxray/storage.html +22 -0
- foxray/django/urls.py +14 -0
- foxray/django/views.py +69 -0
- foxray/standalone/__init__.py +2 -0
- foxray/standalone/cli.py +158 -0
- foxray/standalone/config.py +66 -0
- foxray/standalone/mail.py +56 -0
- foxray/standalone/store.py +105 -0
- foxray/standalone/web.py +783 -0
- foxray-0.1.2.dist-info/METADATA +308 -0
- foxray-0.1.2.dist-info/RECORD +55 -0
- foxray-0.1.2.dist-info/WHEEL +5 -0
- foxray-0.1.2.dist-info/entry_points.txt +2 -0
- foxray-0.1.2.dist-info/licenses/LICENSE +21 -0
- foxray-0.1.2.dist-info/licenses/NOTICE +34 -0
- foxray-0.1.2.dist-info/top_level.txt +1 -0
foxray/__init__.py
ADDED
foxray/__main__.py
ADDED
foxray/core/__init__.py
ADDED
foxray/core/alerts.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Alerts: decided inside the ingest transaction, written to the
|
|
2
|
+
``foxray_alert`` outbox, and sent after commit by a short background
|
|
3
|
+
thread. A send that fails is retried with backoff; nothing is lost if the
|
|
4
|
+
mail server is down for a while.
|
|
5
|
+
|
|
6
|
+
Issue alerts fire on a new issue and on a regression (a resolved issue seen
|
|
7
|
+
again, or an ignored one past its "until"). Repeats never alert, and a
|
|
8
|
+
regression within the cooldown of the issue's last alert stays quiet.
|
|
9
|
+
Per project: new and/or regression, a minimum level, and environments."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import threading
|
|
16
|
+
import urllib.request
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timedelta
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
from .models import Alert, Issue, Monitor, Project, level_rank
|
|
22
|
+
from .settings import Settings
|
|
23
|
+
from .util import utcnow
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger("foxray.alerts")
|
|
26
|
+
|
|
27
|
+
KIND_LABELS = {
|
|
28
|
+
"new_issue": "New issue",
|
|
29
|
+
"regression": "Regression",
|
|
30
|
+
"unignored": "Back after ignore",
|
|
31
|
+
"monitor_error": "Monitor failed",
|
|
32
|
+
"monitor_missed": "Monitor missed a check-in",
|
|
33
|
+
"monitor_timeout": "Monitor timed out",
|
|
34
|
+
"store_full": "Storage full",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Links:
|
|
39
|
+
"""Where things live in the UI, for the links in alerts."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, base_url: str = ""):
|
|
42
|
+
self.base_url = (base_url or "").rstrip("/")
|
|
43
|
+
|
|
44
|
+
def issue(self, issue_id: int) -> str:
|
|
45
|
+
return f"{self.base_url}/issues/{issue_id}"
|
|
46
|
+
|
|
47
|
+
def monitor(self, monitor_id: int) -> str:
|
|
48
|
+
return f"{self.base_url}/monitors/{monitor_id}"
|
|
49
|
+
|
|
50
|
+
def storage(self) -> str:
|
|
51
|
+
return f"{self.base_url}/storage"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class AlertDraft:
|
|
56
|
+
kind: str
|
|
57
|
+
subject: str
|
|
58
|
+
body: str
|
|
59
|
+
payload: dict = field(default_factory=dict)
|
|
60
|
+
issue_id: int | None = None
|
|
61
|
+
monitor_id: int | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _split(value: str) -> list[str]:
|
|
65
|
+
return [p.strip() for p in (value or "").split(",") if p.strip()]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def should_alert_issue(project: Project, settings: Settings, issue: Issue, kind: str,
|
|
69
|
+
environment: str, now: datetime) -> bool:
|
|
70
|
+
if kind == "new_issue" and not project.alert_new:
|
|
71
|
+
return False
|
|
72
|
+
if kind in ("regression", "unignored") and not project.alert_regression:
|
|
73
|
+
return False
|
|
74
|
+
if project.alert_min_level and level_rank(issue.level) < level_rank(project.alert_min_level):
|
|
75
|
+
return False
|
|
76
|
+
envs = _split(project.alert_environments)
|
|
77
|
+
if envs and environment not in envs:
|
|
78
|
+
return False
|
|
79
|
+
if kind != "new_issue" and issue.last_alerted_at is not None:
|
|
80
|
+
if now - issue.last_alerted_at < timedelta(seconds=settings.alert_cooldown_seconds):
|
|
81
|
+
return False
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def issue_draft(project: Project, issue: Issue, kind: str, ev: dict, links: Links) -> AlertDraft:
|
|
86
|
+
label = KIND_LABELS.get(kind, kind)
|
|
87
|
+
url = links.issue(issue.id)
|
|
88
|
+
env = ev.get("environment") or ""
|
|
89
|
+
lines = [
|
|
90
|
+
f"{label} in {project.name}: {issue.title}",
|
|
91
|
+
"",
|
|
92
|
+
f"Where: {issue.culprit or '-'}",
|
|
93
|
+
f"Level: {issue.level}",
|
|
94
|
+
f"Environment: {env or '-'}",
|
|
95
|
+
f"Release: {ev.get('release') or '-'}",
|
|
96
|
+
f"Seen: {issue.times_seen} time(s), first {issue.first_seen:%Y-%m-%d %H:%M} UTC",
|
|
97
|
+
"",
|
|
98
|
+
url,
|
|
99
|
+
]
|
|
100
|
+
payload = {
|
|
101
|
+
"kind": kind, "project": project.name, "project_id": project.id, "issue_id": issue.id,
|
|
102
|
+
"title": issue.title, "culprit": issue.culprit, "level": issue.level, "environment": env,
|
|
103
|
+
"release": ev.get("release") or "", "times_seen": issue.times_seen,
|
|
104
|
+
"first_seen": issue.first_seen.isoformat(), "last_seen": issue.last_seen.isoformat(),
|
|
105
|
+
"event_id": ev.get("event_id"), "url": url,
|
|
106
|
+
}
|
|
107
|
+
return AlertDraft(kind, f"[Foxray] {label}: {issue.title}"[:300], "\n".join(lines), payload, issue_id=issue.id)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def monitor_draft(project: Project, monitor: Monitor, kind: str, links: Links, detail: str = "") -> AlertDraft:
|
|
111
|
+
label = KIND_LABELS.get(kind, kind)
|
|
112
|
+
url = links.monitor(monitor.id)
|
|
113
|
+
schedule = f"{monitor.schedule_type} {monitor.schedule_value} {monitor.schedule_unit}".strip()
|
|
114
|
+
body = "\n".join([f"{label} in {project.name}: {monitor.slug}", "", f"Schedule: {schedule or '-'}",
|
|
115
|
+
detail, "", url])
|
|
116
|
+
payload = {"kind": kind, "project": project.name, "project_id": project.id, "monitor_id": monitor.id,
|
|
117
|
+
"monitor": monitor.slug, "schedule": schedule, "detail": detail, "url": url}
|
|
118
|
+
return AlertDraft(kind, f"[Foxray] {label}: {monitor.slug}", body, payload, monitor_id=monitor.id)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def store_full_draft(size: int, cap: int, links: Links) -> AlertDraft:
|
|
122
|
+
mb = lambda n: f"{n / 1048576:.1f} MB" # noqa: E731
|
|
123
|
+
body = (f"Foxray's store is at {mb(size)}, over its cap of {mb(cap)}, even after removing old event "
|
|
124
|
+
f"bodies.\n\nIt is now counting only: issues are still grouped, counted and alerted, but new event "
|
|
125
|
+
f"bodies are not stored until there is room again.\n\n{links.storage()}")
|
|
126
|
+
return AlertDraft("store_full", "[Foxray] Storage full: counting only", body,
|
|
127
|
+
{"kind": "store_full", "size": size, "cap": cap, "url": links.storage()})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def queue_alert(store, project: Project | None, draft: AlertDraft, channels: list[str], now: datetime) -> None:
|
|
131
|
+
for channel in channels or ["log"]:
|
|
132
|
+
store.add_alert(project_id=project.id if project else None, kind=draft.kind, channel=channel,
|
|
133
|
+
subject=draft.subject, body=draft.body, payload=draft.payload, now=now,
|
|
134
|
+
issue_id=draft.issue_id, monitor_id=draft.monitor_id)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def post_webhook(url: str, alert: Alert, timeout: float = 10) -> str:
|
|
138
|
+
try:
|
|
139
|
+
payload = json.loads(alert.payload)
|
|
140
|
+
except ValueError:
|
|
141
|
+
payload = {}
|
|
142
|
+
payload.setdefault("subject", alert.subject)
|
|
143
|
+
payload.setdefault("text", alert.body)
|
|
144
|
+
data = json.dumps(payload).encode()
|
|
145
|
+
req = urllib.request.Request(url, data=data, method="POST",
|
|
146
|
+
headers={"Content-Type": "application/json", "User-Agent": "foxray"})
|
|
147
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 - admin-configured URL
|
|
148
|
+
if resp.status >= 300:
|
|
149
|
+
raise RuntimeError(f"webhook answered {resp.status}")
|
|
150
|
+
return f"webhook {resp.status}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def webhook_sender(default_url: str):
|
|
154
|
+
def send(alert: Alert, project: Project | None) -> str:
|
|
155
|
+
url = (project.alert_webhook if project else "") or default_url
|
|
156
|
+
if not url:
|
|
157
|
+
return "no webhook URL"
|
|
158
|
+
return post_webhook(url, alert)
|
|
159
|
+
return send
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
Sender = Callable[[Alert, "Project | None"], "str | None"]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Outbox:
|
|
166
|
+
"""Sends pending alerts. Safe to run in several processes at once: each
|
|
167
|
+
alert is claimed with a conditional UPDATE before it is sent."""
|
|
168
|
+
|
|
169
|
+
def __init__(self, store, settings: Settings, senders: dict[str, Sender], clock=utcnow):
|
|
170
|
+
self.store = store
|
|
171
|
+
self.settings = settings
|
|
172
|
+
self.senders = senders
|
|
173
|
+
self.clock = clock
|
|
174
|
+
self._running = threading.Lock()
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def backoff(attempts: int) -> timedelta:
|
|
178
|
+
return timedelta(seconds=min(3600, 60 * (2 ** min(attempts, 6))))
|
|
179
|
+
|
|
180
|
+
def flush(self, now: datetime | None = None, limit: int = 50) -> dict:
|
|
181
|
+
now = now or self.clock()
|
|
182
|
+
sent = failed = 0
|
|
183
|
+
give_up = timedelta(days=self.settings.failed_alert_retention_days)
|
|
184
|
+
projects: dict = {}
|
|
185
|
+
for alert in self.store.due_alerts(now, limit):
|
|
186
|
+
if now - alert.created_at > give_up:
|
|
187
|
+
self.store.mark_alert_error(alert.id, alert.last_error or "gave up", failed=True)
|
|
188
|
+
failed += 1
|
|
189
|
+
continue
|
|
190
|
+
if not self.store.claim_alert(alert.id, now, now + self.backoff(alert.attempts)):
|
|
191
|
+
continue
|
|
192
|
+
if alert.project_id is not None and alert.project_id not in projects:
|
|
193
|
+
projects[alert.project_id] = self.store.get_project(alert.project_id)
|
|
194
|
+
project = projects.get(alert.project_id)
|
|
195
|
+
sender = self.senders.get(alert.channel)
|
|
196
|
+
try:
|
|
197
|
+
if sender is None:
|
|
198
|
+
log.warning("alert (no channel configured): %s", alert.subject)
|
|
199
|
+
note = "logged only: no email or webhook configured"
|
|
200
|
+
else:
|
|
201
|
+
note = sender(alert, project) or ""
|
|
202
|
+
self.store.mark_alert_sent(alert.id, self.clock(), note)
|
|
203
|
+
sent += 1
|
|
204
|
+
except Exception as exc: # noqa: BLE001 - any failure means retry later
|
|
205
|
+
log.warning("alert %s via %s failed: %s", alert.id, alert.channel, exc)
|
|
206
|
+
self.store.mark_alert_error(alert.id, f"{type(exc).__name__}: {exc}")
|
|
207
|
+
failed += 1
|
|
208
|
+
return {"sent": sent, "failed": failed}
|
|
209
|
+
|
|
210
|
+
def kick(self, on_thread_exit: Callable | None = None) -> None:
|
|
211
|
+
"""Send due alerts on a short background thread (one per process)."""
|
|
212
|
+
if not self._running.acquire(blocking=False):
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
def run():
|
|
216
|
+
try:
|
|
217
|
+
self.flush()
|
|
218
|
+
except Exception: # noqa: BLE001
|
|
219
|
+
log.exception("sending alerts failed")
|
|
220
|
+
finally:
|
|
221
|
+
self._running.release()
|
|
222
|
+
if on_thread_exit:
|
|
223
|
+
on_thread_exit()
|
|
224
|
+
|
|
225
|
+
threading.Thread(target=run, name="foxray-alerts", daemon=True).start()
|
foxray/core/auth.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Finding the public key an SDK sent: the ``X-Sentry-Auth`` header, an
|
|
2
|
+
``Authorization: Sentry ...`` header, ``sentry_key`` in the query string, or
|
|
3
|
+
the ``dsn`` in an envelope header (used by browser tunnels)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hmac
|
|
8
|
+
from typing import Mapping
|
|
9
|
+
|
|
10
|
+
from .dsn import DsnError, parse_dsn
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_auth_header(value: str) -> dict[str, str]:
|
|
14
|
+
"""``Sentry sentry_key=abc, sentry_version=7, sentry_client=x/1.0``."""
|
|
15
|
+
if not value:
|
|
16
|
+
return {}
|
|
17
|
+
s = value.strip()
|
|
18
|
+
if s[:7].lower() == "sentry ":
|
|
19
|
+
s = s[7:]
|
|
20
|
+
out: dict[str, str] = {}
|
|
21
|
+
for part in s.split(","):
|
|
22
|
+
key, sep, val = part.strip().partition("=")
|
|
23
|
+
if sep and key.strip():
|
|
24
|
+
out[key.strip()] = val.strip().strip('"')
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def extract_public_key(headers: Mapping[str, str], query: Mapping[str, str],
|
|
29
|
+
envelope_header: dict | None = None) -> str | None:
|
|
30
|
+
"""``headers`` must have lower-case names."""
|
|
31
|
+
raw = headers.get("x-sentry-auth")
|
|
32
|
+
if not raw:
|
|
33
|
+
authz = headers.get("authorization", "")
|
|
34
|
+
if authz[:7].lower() == "sentry ":
|
|
35
|
+
raw = authz
|
|
36
|
+
if raw:
|
|
37
|
+
key = parse_auth_header(raw).get("sentry_key")
|
|
38
|
+
if key:
|
|
39
|
+
return key
|
|
40
|
+
key = query.get("sentry_key") if query else None
|
|
41
|
+
if key:
|
|
42
|
+
return key
|
|
43
|
+
if envelope_header and isinstance(envelope_header.get("dsn"), str):
|
|
44
|
+
try:
|
|
45
|
+
return parse_dsn(envelope_header["dsn"]).public_key
|
|
46
|
+
except DsnError:
|
|
47
|
+
return None
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def key_matches(given: str | None, expected: str) -> bool:
|
|
52
|
+
if not given or not expected:
|
|
53
|
+
return False
|
|
54
|
+
return hmac.compare_digest(given.encode(), expected.encode())
|
foxray/core/cron.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Monitor schedules, as the SDKs send them in ``monitor_config``:
|
|
2
|
+
``{"type": "crontab", "value": "0 * * * *"}`` or
|
|
3
|
+
``{"type": "interval", "value": 2, "unit": "hour"}``, with an optional
|
|
4
|
+
IANA ``timezone``."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import calendar
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
14
|
+
except ImportError: # pragma: no cover
|
|
15
|
+
ZoneInfo = None # type: ignore
|
|
16
|
+
ZoneInfoNotFoundError = Exception # type: ignore
|
|
17
|
+
|
|
18
|
+
from .util import UTC
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ScheduleError(ValueError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
MACROS = {
|
|
26
|
+
"@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *",
|
|
27
|
+
"@weekly": "0 0 * * 0", "@daily": "0 0 * * *", "@midnight": "0 0 * * *", "@hourly": "0 * * * *",
|
|
28
|
+
}
|
|
29
|
+
_MONTHS = {m.lower(): i for i, m in enumerate(calendar.month_abbr) if m}
|
|
30
|
+
_DAYS = {"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6}
|
|
31
|
+
UNITS = ("minute", "hour", "day", "week", "month", "year")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _value(token: str, names: dict, lo: int, hi: int) -> int:
|
|
35
|
+
t = token.strip().lower()
|
|
36
|
+
if t in names:
|
|
37
|
+
return names[t]
|
|
38
|
+
if not t.isdigit():
|
|
39
|
+
raise ScheduleError(f"bad crontab value: {token!r}")
|
|
40
|
+
v = int(t)
|
|
41
|
+
if not lo <= v <= hi:
|
|
42
|
+
raise ScheduleError(f"crontab value {v} out of range {lo}-{hi}")
|
|
43
|
+
return v
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _field(spec: str, lo: int, hi: int, names: dict | None = None) -> tuple[frozenset, bool]:
|
|
47
|
+
names = names or {}
|
|
48
|
+
out: set[int] = set()
|
|
49
|
+
star = spec.strip() == "*"
|
|
50
|
+
for part in spec.split(","):
|
|
51
|
+
part = part.strip()
|
|
52
|
+
if not part:
|
|
53
|
+
raise ScheduleError("empty crontab field")
|
|
54
|
+
rng, _, step_s = part.partition("/")
|
|
55
|
+
step = 1
|
|
56
|
+
if step_s:
|
|
57
|
+
if not step_s.isdigit() or int(step_s) == 0:
|
|
58
|
+
raise ScheduleError(f"bad crontab step: {part!r}")
|
|
59
|
+
step = int(step_s)
|
|
60
|
+
if rng == "*":
|
|
61
|
+
start, end = lo, hi
|
|
62
|
+
elif "-" in rng:
|
|
63
|
+
a, b = rng.split("-", 1)
|
|
64
|
+
start, end = _value(a, names, lo, hi), _value(b, names, lo, hi)
|
|
65
|
+
if end < start:
|
|
66
|
+
raise ScheduleError(f"bad crontab range: {part!r}")
|
|
67
|
+
else:
|
|
68
|
+
start = _value(rng, names, lo, hi)
|
|
69
|
+
end = hi if step_s else start
|
|
70
|
+
out.update(range(start, end + 1, step))
|
|
71
|
+
return frozenset(out), star
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Crontab:
|
|
75
|
+
def __init__(self, expr: str):
|
|
76
|
+
expr = (expr or "").strip()
|
|
77
|
+
expr = MACROS.get(expr.lower(), expr)
|
|
78
|
+
parts = expr.split()
|
|
79
|
+
if len(parts) != 5:
|
|
80
|
+
raise ScheduleError("a crontab needs 5 fields")
|
|
81
|
+
self.minutes, _ = _field(parts[0], 0, 59)
|
|
82
|
+
self.hours, _ = _field(parts[1], 0, 23)
|
|
83
|
+
self.doms, self.dom_star = _field(parts[2], 1, 31)
|
|
84
|
+
self.months, _ = _field(parts[3], 1, 12, _MONTHS)
|
|
85
|
+
dows, self.dow_star = _field(parts[4], 0, 7, _DAYS)
|
|
86
|
+
self.dows = frozenset(0 if d == 7 else d for d in dows)
|
|
87
|
+
|
|
88
|
+
def _day_ok(self, d: datetime) -> bool:
|
|
89
|
+
dom_ok = d.day in self.doms
|
|
90
|
+
dow_ok = (d.isoweekday() % 7) in self.dows
|
|
91
|
+
if self.dom_star or self.dow_star:
|
|
92
|
+
return dom_ok and dow_ok
|
|
93
|
+
return dom_ok or dow_ok # cron: either restricted day field may match
|
|
94
|
+
|
|
95
|
+
def next_after(self, after: datetime, tz=UTC) -> datetime:
|
|
96
|
+
"""The first scheduled minute strictly after ``after``."""
|
|
97
|
+
local = after.astimezone(tz).replace(tzinfo=None, second=0, microsecond=0) + timedelta(minutes=1)
|
|
98
|
+
limit = local + timedelta(days=366 * 5)
|
|
99
|
+
while local < limit:
|
|
100
|
+
if local.month not in self.months:
|
|
101
|
+
y, m = (local.year + 1, 1) if local.month == 12 else (local.year, local.month + 1)
|
|
102
|
+
local = local.replace(year=y, month=m, day=1, hour=0, minute=0)
|
|
103
|
+
continue
|
|
104
|
+
if not self._day_ok(local):
|
|
105
|
+
local = (local + timedelta(days=1)).replace(hour=0, minute=0)
|
|
106
|
+
continue
|
|
107
|
+
if local.hour not in self.hours:
|
|
108
|
+
local = (local + timedelta(hours=1)).replace(minute=0)
|
|
109
|
+
continue
|
|
110
|
+
if local.minute not in self.minutes:
|
|
111
|
+
local += timedelta(minutes=1)
|
|
112
|
+
continue
|
|
113
|
+
return local.replace(tzinfo=tz).astimezone(UTC)
|
|
114
|
+
raise ScheduleError("crontab never fires")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@lru_cache(maxsize=256)
|
|
118
|
+
def crontab(expr: str) -> Crontab:
|
|
119
|
+
return Crontab(expr)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def zone(name: str | None):
|
|
123
|
+
if not name or ZoneInfo is None:
|
|
124
|
+
return UTC
|
|
125
|
+
try:
|
|
126
|
+
return ZoneInfo(name)
|
|
127
|
+
except (ZoneInfoNotFoundError, ValueError, OSError):
|
|
128
|
+
return UTC
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def add_interval(dt: datetime, value: int, unit: str) -> datetime:
|
|
132
|
+
if unit == "minute":
|
|
133
|
+
return dt + timedelta(minutes=value)
|
|
134
|
+
if unit == "hour":
|
|
135
|
+
return dt + timedelta(hours=value)
|
|
136
|
+
if unit == "day":
|
|
137
|
+
return dt + timedelta(days=value)
|
|
138
|
+
if unit == "week":
|
|
139
|
+
return dt + timedelta(weeks=value)
|
|
140
|
+
if unit in ("month", "year"):
|
|
141
|
+
months = value * (12 if unit == "year" else 1)
|
|
142
|
+
y, m = divmod(dt.month - 1 + months, 12)
|
|
143
|
+
year, month = dt.year + y, m + 1
|
|
144
|
+
day = min(dt.day, calendar.monthrange(year, month)[1])
|
|
145
|
+
return dt.replace(year=year, month=month, day=day)
|
|
146
|
+
raise ScheduleError(f"bad interval unit: {unit!r}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_schedule(config: dict) -> tuple[str, str, str]:
|
|
150
|
+
"""Validate an SDK ``monitor_config['schedule']``; returns (type, value, unit)."""
|
|
151
|
+
if not isinstance(config, dict):
|
|
152
|
+
raise ScheduleError("schedule must be an object")
|
|
153
|
+
kind = config.get("type")
|
|
154
|
+
if kind == "crontab":
|
|
155
|
+
value = str(config.get("value") or "").strip()
|
|
156
|
+
crontab(value)
|
|
157
|
+
return "crontab", value, ""
|
|
158
|
+
if kind == "interval":
|
|
159
|
+
try:
|
|
160
|
+
value = int(config.get("value"))
|
|
161
|
+
except (TypeError, ValueError):
|
|
162
|
+
raise ScheduleError("interval value must be a number") from None
|
|
163
|
+
unit = str(config.get("unit") or "").lower().rstrip("s")
|
|
164
|
+
if value <= 0 or unit not in UNITS:
|
|
165
|
+
raise ScheduleError("interval needs a positive value and a unit")
|
|
166
|
+
return "interval", str(value), unit
|
|
167
|
+
raise ScheduleError("schedule type must be crontab or interval")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def next_expected(schedule_type: str, value: str, unit: str, tz_name: str, after: datetime) -> datetime | None:
|
|
171
|
+
"""When the next run should start, given the last check-in at ``after``."""
|
|
172
|
+
if schedule_type == "crontab" and value:
|
|
173
|
+
return crontab(value).next_after(after, zone(tz_name))
|
|
174
|
+
if schedule_type == "interval" and value:
|
|
175
|
+
return add_interval(after, int(value), unit)
|
|
176
|
+
return None
|
foxray/core/dsn.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""DSNs: ``scheme://public_key[:secret]@host[:port]/[path/]project_id``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DsnError(ValueError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Dsn:
|
|
15
|
+
scheme: str
|
|
16
|
+
public_key: str
|
|
17
|
+
secret_key: str
|
|
18
|
+
host: str
|
|
19
|
+
port: int | None
|
|
20
|
+
path: str # prefix before the project id, without trailing slash ("" or "/foxray")
|
|
21
|
+
project_id: str
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def netloc(self) -> str:
|
|
25
|
+
return self.host if self.port is None else f"{self.host}:{self.port}"
|
|
26
|
+
|
|
27
|
+
def endpoint(self, kind: str = "envelope") -> str:
|
|
28
|
+
return f"{self.scheme}://{self.netloc}{self.path}/api/{self.project_id}/{kind}/"
|
|
29
|
+
|
|
30
|
+
def __str__(self) -> str:
|
|
31
|
+
secret = f":{self.secret_key}" if self.secret_key else ""
|
|
32
|
+
return f"{self.scheme}://{self.public_key}{secret}@{self.netloc}{self.path}/{self.project_id}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_dsn(value: str) -> Dsn:
|
|
36
|
+
if not value or not isinstance(value, str):
|
|
37
|
+
raise DsnError("empty DSN")
|
|
38
|
+
parts = urlsplit(value.strip())
|
|
39
|
+
if parts.scheme not in ("http", "https"):
|
|
40
|
+
raise DsnError("DSN scheme must be http or https")
|
|
41
|
+
if not parts.username:
|
|
42
|
+
raise DsnError("DSN has no public key")
|
|
43
|
+
if not parts.hostname:
|
|
44
|
+
raise DsnError("DSN has no host")
|
|
45
|
+
path = parts.path.rstrip("/")
|
|
46
|
+
prefix, _, project_id = path.rpartition("/")
|
|
47
|
+
if not project_id:
|
|
48
|
+
raise DsnError("DSN has no project id")
|
|
49
|
+
try:
|
|
50
|
+
port = parts.port
|
|
51
|
+
except ValueError as exc:
|
|
52
|
+
raise DsnError(str(exc)) from None
|
|
53
|
+
return Dsn(parts.scheme, parts.username, parts.password or "", parts.hostname, port, prefix, project_id)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def make_dsn(base_url: str, public_key: str, project_id) -> str:
|
|
57
|
+
"""``base_url`` is where ingest is mounted, e.g. ``https://host`` for the
|
|
58
|
+
standalone server or ``https://host/foxray`` for the Django app."""
|
|
59
|
+
parts = urlsplit((base_url or "http://localhost").strip())
|
|
60
|
+
scheme = parts.scheme or "http"
|
|
61
|
+
netloc = parts.netloc or parts.path
|
|
62
|
+
path = parts.path.rstrip("/") if parts.netloc else ""
|
|
63
|
+
return f"{scheme}://{public_key}@{netloc}{path}/{project_id}"
|