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.
Files changed (55) hide show
  1. foxray/__init__.py +3 -0
  2. foxray/__main__.py +3 -0
  3. foxray/core/__init__.py +2 -0
  4. foxray/core/alerts.py +225 -0
  5. foxray/core/auth.py +54 -0
  6. foxray/core/cron.py +176 -0
  7. foxray/core/dsn.py +63 -0
  8. foxray/core/envelope.py +166 -0
  9. foxray/core/filters.py +173 -0
  10. foxray/core/fingerprint.py +82 -0
  11. foxray/core/ingest.py +255 -0
  12. foxray/core/maintenance.py +321 -0
  13. foxray/core/models.py +178 -0
  14. foxray/core/monitors.py +150 -0
  15. foxray/core/normalize.py +358 -0
  16. foxray/core/present.py +146 -0
  17. foxray/core/scrub.py +96 -0
  18. foxray/core/settings.py +96 -0
  19. foxray/core/sql.py +996 -0
  20. foxray/core/storage.py +139 -0
  21. foxray/core/util.py +101 -0
  22. foxray/django/__init__.py +4 -0
  23. foxray/django/admin.py +406 -0
  24. foxray/django/apps.py +21 -0
  25. foxray/django/conf.py +39 -0
  26. foxray/django/management/__init__.py +0 -0
  27. foxray/django/management/commands/__init__.py +0 -0
  28. foxray/django/management/commands/foxray_create_project.py +18 -0
  29. foxray/django/management/commands/foxray_prune.py +20 -0
  30. foxray/django/management/commands/foxray_tick.py +16 -0
  31. foxray/django/migrations/0001_initial.py +696 -0
  32. foxray/django/migrations/0002_default_project.py +18 -0
  33. foxray/django/migrations/__init__.py +0 -0
  34. foxray/django/models.py +275 -0
  35. foxray/django/runtime.py +162 -0
  36. foxray/django/sdk.py +231 -0
  37. foxray/django/store.py +48 -0
  38. foxray/django/templates/admin/foxray/_style.html +21 -0
  39. foxray/django/templates/admin/foxray/issue_detail.html +49 -0
  40. foxray/django/templates/admin/foxray/storage.html +22 -0
  41. foxray/django/urls.py +14 -0
  42. foxray/django/views.py +69 -0
  43. foxray/standalone/__init__.py +2 -0
  44. foxray/standalone/cli.py +158 -0
  45. foxray/standalone/config.py +66 -0
  46. foxray/standalone/mail.py +56 -0
  47. foxray/standalone/store.py +105 -0
  48. foxray/standalone/web.py +783 -0
  49. foxray-0.1.2.dist-info/METADATA +308 -0
  50. foxray-0.1.2.dist-info/RECORD +55 -0
  51. foxray-0.1.2.dist-info/WHEEL +5 -0
  52. foxray-0.1.2.dist-info/entry_points.txt +2 -0
  53. foxray-0.1.2.dist-info/licenses/LICENSE +21 -0
  54. foxray-0.1.2.dist-info/licenses/NOTICE +34 -0
  55. foxray-0.1.2.dist-info/top_level.txt +1 -0
foxray/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Foxray: a light error tracker that speaks the Sentry SDK protocol."""
2
+
3
+ __version__ = "0.1.2"
foxray/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from foxray.standalone.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,2 @@
1
+ """Foxray core: protocol parsing, normalising, grouping, scrubbing, rules and
2
+ the SQL store. Pure standard library; never imports Django."""
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}"