devalerts 0.1.5__tar.gz → 0.2.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devalerts
3
- Version: 0.1.5
3
+ Version: 0.2.1
4
4
  Summary: Send unhandled Python exceptions straight to a Telegram chat. No backend, no account, no database — just your own bot token.
5
5
  Keywords: telegram,error-tracking,exception-monitoring,alerts,logging,monitoring,asgi
6
6
  Author: sslinNn
@@ -77,7 +77,13 @@ chat instead of a log file nobody's watching.
77
77
  2. Message your bot once (or add it to a group) so it's allowed to message you back.
78
78
  3. Get your chat id — message [@userinfobot](https://t.me/userinfobot), or call
79
79
  `https://api.telegram.org/bot<TOKEN>/getUpdates` after step 2 and read `message.chat.id`.
80
- 4. In your app, as early as possible:
80
+ 4. Verify it's wired up correctly before touching any code:
81
+
82
+ ```
83
+ uv run devalerts test --bot-token 123456:ABC-DEF... --chat-id 123456789
84
+ ```
85
+
86
+ 5. In your app, as early as possible:
81
87
 
82
88
  ```python
83
89
  import devalerts
@@ -88,6 +94,10 @@ devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
88
94
  That's it — any unhandled exception (including ones raised in threads) now
89
95
  also lands in your Telegram chat.
90
96
 
97
+ The traceback itself arrives folded into a collapsed quote — the exception
98
+ type, message, and host are visible right away, tap to expand the full
99
+ traceback. Keeps a big stack trace from taking over the chat.
100
+
91
101
  ## Grouping, rate limiting, and the dashboard
92
102
 
93
103
  Exceptions are grouped by fingerprint (exception type + file + line where it
@@ -109,6 +119,48 @@ uv run devalerts dashboard
109
119
 
110
120
  ![devalerts dashboard output](https://raw.githubusercontent.com/sslinNn/devalerts/main/docs/dashboard.svg)
111
121
 
122
+ Pass `--json` for machine-readable output. Silence a noisy group without
123
+ touching code — the `ID` column accepts any unique prefix:
124
+
125
+ ```
126
+ uv run devalerts mute abc12345
127
+ uv run devalerts unmute abc12345
128
+ uv run devalerts clear abc12345 # or: devalerts clear --all
129
+ ```
130
+
131
+ Unmuting resends the next occurrence with the accumulated skip count, same
132
+ as a rate-limit window expiring.
133
+
134
+ Error groups that keep piling up while suppressed are chronic: each such
135
+ resend doubles the effective `rate_limit_seconds` for that group (capped at
136
+ 8x), so a crash loop backs itself off instead of paging you every window
137
+ forever. A group that goes quiet and reappears once resets to the base rate
138
+ immediately. The dashboard shows the active multiplier (`● sending ×4`).
139
+
140
+ ## Context: hostname and tags
141
+
142
+ Every alert automatically includes the sending host, so you can tell which
143
+ process/server it came from when one bot serves several:
144
+
145
+ ```python
146
+ devalerts.init(bot_token="...", chat_id=123456789, tags={"env": "production"})
147
+ ```
148
+
149
+ ```
150
+ 🔴 ValueError: boom
151
+ 🖥️ prod-web-2 (env=production)
152
+ ```
153
+
154
+ Add ad-hoc tags to a single call — they override `init()`'s tags on a key
155
+ collision:
156
+
157
+ ```python
158
+ devalerts.report(extra={"request_id": "abc123"})
159
+
160
+ @devalerts.capture(extra={"job": "nightly-sync"})
161
+ def run_job(): ...
162
+ ```
163
+
112
164
  ## Manually reporting a caught exception
113
165
 
114
166
  ```python
@@ -148,6 +200,24 @@ Only exceptions that actually escape as server errors get reported — routing
148
200
  404s and raised `HTTPException`s are already turned into responses by the
149
201
  framework before the middleware sees them.
150
202
 
203
+ ## Celery
204
+
205
+ Same problem as ASGI apps: `init()`'s excepthook never sees exceptions raised
206
+ inside a task, because Celery catches them itself to record the task's
207
+ `FAILURE` state. Call `init_celery()` in addition to `init()`:
208
+
209
+ ```python
210
+ devalerts.init(bot_token="...", chat_id=123456789)
211
+ devalerts.init_celery()
212
+ ```
213
+
214
+ This connects to Celery's `task_failure` signal (fired once a task has
215
+ genuinely failed — retries exhausted or none configured, so retried tasks
216
+ don't spam an alert per attempt) and reports through the same
217
+ grouping/rate-limiting/redaction path as everything else, tagged with the
218
+ task name and id automatically. Requires Celery to already be installed in
219
+ the worker process — it's imported lazily, not a devalerts dependency.
220
+
151
221
  ## Why not Sentry?
152
222
 
153
223
  If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
@@ -178,8 +248,8 @@ you want dedup state to survive restarts — it self-recreates otherwise.
178
248
  Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
179
249
 
180
250
  **Works with Celery / background workers?**
181
- Yes, `init()` in the worker process the same way as in a web process. For
182
- tasks you catch yourself, use `report()` or `@devalerts.capture()`.
251
+ Yes — call [`init_celery()`](#celery) in addition to `init()` to catch
252
+ exceptions raised inside tasks, which the excepthook alone won't see.
183
253
 
184
254
  **Works on Windows / Linux / macOS?**
185
255
  Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
@@ -196,6 +266,9 @@ paths.
196
266
  - Basic secret redaction only (a few common token/key patterns) — do not
197
267
  rely on this for sensitive production data; scrub what you can before it
198
268
  ever reaches an exception message.
269
+ - If Telegram delivery fails after retrying, the alert (already redacted, if
270
+ `redact=True`) is appended to `~/.devalerts/failed.log` instead of being
271
+ dropped — clean it up like any other local log file.
199
272
 
200
273
  ## What this does NOT do (by design)
201
274
 
@@ -50,7 +50,13 @@ chat instead of a log file nobody's watching.
50
50
  2. Message your bot once (or add it to a group) so it's allowed to message you back.
51
51
  3. Get your chat id — message [@userinfobot](https://t.me/userinfobot), or call
52
52
  `https://api.telegram.org/bot<TOKEN>/getUpdates` after step 2 and read `message.chat.id`.
53
- 4. In your app, as early as possible:
53
+ 4. Verify it's wired up correctly before touching any code:
54
+
55
+ ```
56
+ uv run devalerts test --bot-token 123456:ABC-DEF... --chat-id 123456789
57
+ ```
58
+
59
+ 5. In your app, as early as possible:
54
60
 
55
61
  ```python
56
62
  import devalerts
@@ -61,6 +67,10 @@ devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
61
67
  That's it — any unhandled exception (including ones raised in threads) now
62
68
  also lands in your Telegram chat.
63
69
 
70
+ The traceback itself arrives folded into a collapsed quote — the exception
71
+ type, message, and host are visible right away, tap to expand the full
72
+ traceback. Keeps a big stack trace from taking over the chat.
73
+
64
74
  ## Grouping, rate limiting, and the dashboard
65
75
 
66
76
  Exceptions are grouped by fingerprint (exception type + file + line where it
@@ -82,6 +92,48 @@ uv run devalerts dashboard
82
92
 
83
93
  ![devalerts dashboard output](https://raw.githubusercontent.com/sslinNn/devalerts/main/docs/dashboard.svg)
84
94
 
95
+ Pass `--json` for machine-readable output. Silence a noisy group without
96
+ touching code — the `ID` column accepts any unique prefix:
97
+
98
+ ```
99
+ uv run devalerts mute abc12345
100
+ uv run devalerts unmute abc12345
101
+ uv run devalerts clear abc12345 # or: devalerts clear --all
102
+ ```
103
+
104
+ Unmuting resends the next occurrence with the accumulated skip count, same
105
+ as a rate-limit window expiring.
106
+
107
+ Error groups that keep piling up while suppressed are chronic: each such
108
+ resend doubles the effective `rate_limit_seconds` for that group (capped at
109
+ 8x), so a crash loop backs itself off instead of paging you every window
110
+ forever. A group that goes quiet and reappears once resets to the base rate
111
+ immediately. The dashboard shows the active multiplier (`● sending ×4`).
112
+
113
+ ## Context: hostname and tags
114
+
115
+ Every alert automatically includes the sending host, so you can tell which
116
+ process/server it came from when one bot serves several:
117
+
118
+ ```python
119
+ devalerts.init(bot_token="...", chat_id=123456789, tags={"env": "production"})
120
+ ```
121
+
122
+ ```
123
+ 🔴 ValueError: boom
124
+ 🖥️ prod-web-2 (env=production)
125
+ ```
126
+
127
+ Add ad-hoc tags to a single call — they override `init()`'s tags on a key
128
+ collision:
129
+
130
+ ```python
131
+ devalerts.report(extra={"request_id": "abc123"})
132
+
133
+ @devalerts.capture(extra={"job": "nightly-sync"})
134
+ def run_job(): ...
135
+ ```
136
+
85
137
  ## Manually reporting a caught exception
86
138
 
87
139
  ```python
@@ -121,6 +173,24 @@ Only exceptions that actually escape as server errors get reported — routing
121
173
  404s and raised `HTTPException`s are already turned into responses by the
122
174
  framework before the middleware sees them.
123
175
 
176
+ ## Celery
177
+
178
+ Same problem as ASGI apps: `init()`'s excepthook never sees exceptions raised
179
+ inside a task, because Celery catches them itself to record the task's
180
+ `FAILURE` state. Call `init_celery()` in addition to `init()`:
181
+
182
+ ```python
183
+ devalerts.init(bot_token="...", chat_id=123456789)
184
+ devalerts.init_celery()
185
+ ```
186
+
187
+ This connects to Celery's `task_failure` signal (fired once a task has
188
+ genuinely failed — retries exhausted or none configured, so retried tasks
189
+ don't spam an alert per attempt) and reports through the same
190
+ grouping/rate-limiting/redaction path as everything else, tagged with the
191
+ task name and id automatically. Requires Celery to already be installed in
192
+ the worker process — it's imported lazily, not a devalerts dependency.
193
+
124
194
  ## Why not Sentry?
125
195
 
126
196
  If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
@@ -151,8 +221,8 @@ you want dedup state to survive restarts — it self-recreates otherwise.
151
221
  Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
152
222
 
153
223
  **Works with Celery / background workers?**
154
- Yes, `init()` in the worker process the same way as in a web process. For
155
- tasks you catch yourself, use `report()` or `@devalerts.capture()`.
224
+ Yes — call [`init_celery()`](#celery) in addition to `init()` to catch
225
+ exceptions raised inside tasks, which the excepthook alone won't see.
156
226
 
157
227
  **Works on Windows / Linux / macOS?**
158
228
  Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
@@ -169,6 +239,9 @@ paths.
169
239
  - Basic secret redaction only (a few common token/key patterns) — do not
170
240
  rely on this for sensitive production data; scrub what you can before it
171
241
  ever reaches an exception message.
242
+ - If Telegram delivery fails after retrying, the alert (already redacted, if
243
+ `redact=True`) is appended to `~/.devalerts/failed.log` instead of being
244
+ dropped — clean it up like any other local log file.
172
245
 
173
246
  ## What this does NOT do (by design)
174
247
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devalerts"
3
- version = "0.1.5"
3
+ version = "0.2.1"
4
4
  description = "Send unhandled Python exceptions straight to a Telegram chat. No backend, no account, no database — just your own bot token."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -48,6 +48,7 @@ build-backend = "uv_build"
48
48
 
49
49
  [dependency-groups]
50
50
  dev = [
51
+ "celery>=5.3.0",
51
52
  "mypy>=1.19.1",
52
53
  "pre-commit>=4.3.0",
53
54
  "pytest>=8.4.2",
@@ -9,10 +9,11 @@ from types import TracebackType
9
9
  from typing import Callable, Literal, Optional, TypedDict
10
10
 
11
11
  from ._alert import _format_alert, _redact
12
+ from ._celery import init_celery
12
13
  from ._store import _DEFAULT_RATE_LIMIT_SECONDS, _fingerprint, _should_send
13
14
  from ._telegram import _send_telegram_message
14
15
 
15
- __all__: list[str] = ["init", "report", "capture", "ASGIMiddleware"]
16
+ __all__: list[str] = ["init", "report", "capture", "ASGIMiddleware", "init_celery"]
16
17
 
17
18
  _ExceptHook = Callable[
18
19
  [type[BaseException], BaseException, Optional[TracebackType]], object
@@ -25,6 +26,7 @@ class _State(TypedDict):
25
26
  chat_id: int | str | None
26
27
  redact: bool
27
28
  rate_limit_seconds: int
29
+ tags: dict[str, str]
28
30
  # Seeded with the real default hooks below (never None) -- _excepthook/
29
31
  # _threading_excepthook can only ever fire after init() has replaced
30
32
  # sys.excepthook/threading.excepthook, by which point these are always
@@ -38,12 +40,15 @@ _state: _State = {
38
40
  "chat_id": None,
39
41
  "redact": True,
40
42
  "rate_limit_seconds": _DEFAULT_RATE_LIMIT_SECONDS,
43
+ "tags": {},
41
44
  "prev_excepthook": sys.excepthook,
42
45
  "prev_threading_excepthook": threading.excepthook,
43
46
  }
44
47
 
45
48
 
46
- def _send_exception(exc_type, exc_value, tb) -> None:
49
+ def _send_exception(
50
+ exc_type, exc_value, tb, extra: dict[str, str] | None = None
51
+ ) -> None:
47
52
  if _state["bot_token"] is None or _state["chat_id"] is None:
48
53
  print("devalerts: init() was not called, dropping alert", file=sys.stderr)
49
54
  return
@@ -53,7 +58,8 @@ def _send_exception(exc_type, exc_value, tb) -> None:
53
58
  )
54
59
  if not send:
55
60
  return
56
- message = _format_alert(exc_type, exc_value, tb, skipped=skipped)
61
+ tags = {**_state["tags"], **(extra or {})}
62
+ message = _format_alert(exc_type, exc_value, tb, skipped=skipped, tags=tags)
57
63
  if _state["redact"]:
58
64
  message = _redact(message)
59
65
  _send_telegram_message(_state["bot_token"], _state["chat_id"], message)
@@ -87,12 +93,14 @@ def init(
87
93
  *,
88
94
  redact: bool = True,
89
95
  rate_limit_seconds: int = _DEFAULT_RATE_LIMIT_SECONDS,
96
+ tags: dict[str, str] | None = None,
90
97
  ) -> None:
91
98
  """Install a global exception hook that sends unhandled exceptions to Telegram."""
92
99
  _state["bot_token"] = bot_token
93
100
  _state["chat_id"] = chat_id
94
101
  _state["redact"] = redact
95
102
  _state["rate_limit_seconds"] = rate_limit_seconds
103
+ _state["tags"] = tags or {}
96
104
  # ponytail: guard against calling init() twice capturing our own hook as
97
105
  # "previous" -- that would make _excepthook chain to itself and recurse
98
106
  # forever on the next crash, violating "must never raise".
@@ -104,7 +112,9 @@ def init(
104
112
  threading.excepthook = _threading_excepthook
105
113
 
106
114
 
107
- def report(exc: BaseException | None = None) -> None:
115
+ def report(
116
+ exc: BaseException | None = None, *, extra: dict[str, str] | None = None
117
+ ) -> None:
108
118
  """Manually send a caught exception to Telegram."""
109
119
  if exc is None:
110
120
  exc_type, exc_value, tb = sys.exc_info()
@@ -114,7 +124,7 @@ def report(exc: BaseException | None = None) -> None:
114
124
  )
115
125
  else:
116
126
  exc_type, exc_value, tb = type(exc), exc, exc.__traceback__
117
- _send_exception(exc_type, exc_value, tb)
127
+ _send_exception(exc_type, exc_value, tb, extra=extra)
118
128
 
119
129
 
120
130
  class capture(contextlib.ContextDecorator):
@@ -122,12 +132,15 @@ class capture(contextlib.ContextDecorator):
122
132
  function, then re-raise it. Use ``@capture()`` on a function instead of wrapping
123
133
  its body in a manual ``try/except`` or ``with`` block."""
124
134
 
135
+ def __init__(self, *, extra: dict[str, str] | None = None) -> None:
136
+ self._extra = extra
137
+
125
138
  def __enter__(self) -> "capture":
126
139
  return self
127
140
 
128
141
  def __exit__(self, exc_type, exc_value, tb) -> Literal[False]:
129
142
  if exc_type is not None:
130
- _send_exception(exc_type, exc_value, tb)
143
+ _send_exception(exc_type, exc_value, tb, extra=self._extra)
131
144
  return False
132
145
 
133
146
 
@@ -0,0 +1,71 @@
1
+ """Alert message formatting and secret redaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import socket
7
+ import traceback
8
+
9
+ _MAX_MESSAGE_LENGTH = 4096
10
+
11
+
12
+ def _escape_html(text: str) -> str:
13
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
14
+
15
+
16
+ def _format_context(tags: dict[str, str] | None) -> str:
17
+ try:
18
+ hostname = socket.gethostname()
19
+ except OSError:
20
+ hostname = "unknown-host"
21
+ line = f"🖥️ {hostname}"
22
+ if tags:
23
+ line += " (" + ", ".join(f"{key}={value}" for key, value in tags.items()) + ")"
24
+ return line
25
+
26
+
27
+ def _format_alert(
28
+ exc_type, exc_value, tb, skipped: int = 0, tags: dict[str, str] | None = None
29
+ ) -> str:
30
+ header = f"\U0001f534 {exc_type.__name__}: {exc_value}\n{_format_context(tags)}"
31
+ if skipped:
32
+ header += f"\n⚠️ Повторилась ещё {skipped} раз(а) с последнего алерта"
33
+ body = "".join(traceback.format_exception(exc_type, exc_value, tb))
34
+
35
+ if len(header) + 2 + len(body) > _MAX_MESSAGE_LENGTH:
36
+ marker = "\n\n...(truncated)...\n"
37
+ # ponytail: header itself can exceed the limit if exc_value's str() is huge
38
+ # (e.g. a validation error echoing a large payload) — keep can go negative,
39
+ # so clamp it; the header itself gets hard-truncated as a backstop. The "2"
40
+ # accounts for the "\n\n" separator joined in below.
41
+ keep = max(_MAX_MESSAGE_LENGTH - len(header) - 2 - len(marker), 0)
42
+ body = f"{marker}{body[-keep:]}" if keep else ""
43
+ header = header[:_MAX_MESSAGE_LENGTH]
44
+
45
+ # Budgeted above against the plain text -- Telegram's 4096-char limit
46
+ # applies to the parsed (tag-stripped) text, not the raw HTML we send.
47
+ escaped_header = _escape_html(header)
48
+ if not body:
49
+ return escaped_header
50
+ return (
51
+ f"{escaped_header}\n\n<blockquote expandable>{_escape_html(body)}</blockquote>"
52
+ )
53
+
54
+
55
+ # ponytail: fixed pattern list, not exhaustive — catches common
56
+ # token/key shapes only. Upgrade to entropy-based detection if
57
+ # real users report leaked secrets slipping through.
58
+ _REDACT_PATTERNS = [
59
+ (re.compile(r"AKIA[0-9A-Z]{16}"), "[REDACTED]"),
60
+ (re.compile(r"Bearer\s+[A-Za-z0-9\-_.]+", re.IGNORECASE), "Bearer [REDACTED]"),
61
+ (
62
+ re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[=:]\s*\S+"),
63
+ r"\1=[REDACTED]",
64
+ ),
65
+ ]
66
+
67
+
68
+ def _redact(text: str) -> str:
69
+ for pattern, replacement in _REDACT_PATTERNS:
70
+ text = pattern.sub(replacement, text)
71
+ return text
@@ -0,0 +1,45 @@
1
+ """Optional Celery integration: report task failures that Celery's own trace
2
+ machinery swallows before they ever reach sys.excepthook -- the same problem
3
+ ASGIMiddleware solves for FastAPI/Starlette request errors."""
4
+
5
+ from __future__ import annotations
6
+
7
+ _connected = False
8
+
9
+
10
+ def init_celery() -> None:
11
+ """Report Celery task failures to Telegram. Call in addition to init(),
12
+ which never sees exceptions raised inside a task -- Celery catches those
13
+ itself to record the task's FAILURE state.
14
+
15
+ Usage::
16
+
17
+ devalerts.init(bot_token="...", chat_id=123456789)
18
+ devalerts.init_celery()
19
+
20
+ Requires Celery to already be installed in the worker process (not a
21
+ devalerts dependency -- imported lazily here).
22
+ """
23
+ global _connected
24
+ if _connected:
25
+ return
26
+ from celery.signals import task_failure # type: ignore[import-untyped]
27
+
28
+ task_failure.connect(_on_task_failure, weak=False)
29
+ _connected = True
30
+
31
+
32
+ def _on_task_failure(
33
+ sender=None, task_id=None, exception=None, traceback=None, **_kwargs
34
+ ) -> None:
35
+ from . import _send_exception
36
+
37
+ if exception is None:
38
+ return
39
+ tags: dict[str, str] = {}
40
+ if task_id is not None:
41
+ tags["task_id"] = str(task_id)
42
+ if sender is not None:
43
+ tags["task"] = getattr(sender, "name", type(sender).__name__)
44
+ exc = exception.with_traceback(traceback) if traceback is not None else exception
45
+ _send_exception(type(exc), exc, exc.__traceback__, extra=tags)
@@ -0,0 +1,191 @@
1
+ """Local SQLite state: fingerprint-based grouping, dedup, and rate limiting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import sqlite3
7
+ import sys
8
+ import time
9
+ import traceback
10
+ from pathlib import Path
11
+
12
+ _DB_PATH = Path.home() / ".devalerts" / "state.db"
13
+ _RETENTION_SECONDS = 7 * 24 * 3600
14
+ _DEFAULT_RATE_LIMIT_SECONDS = 300
15
+ # ponytail: fixed cap, not configurable -- upgrade to an init() param if
16
+ # users report the ceiling being wrong for their crash-loop cadence.
17
+ _MAX_BACKOFF_MULTIPLIER = 8
18
+
19
+
20
+ def _fingerprint(exc_type, tb) -> tuple[str, str]:
21
+ frames = traceback.extract_tb(tb)
22
+ location = f"{frames[-1].filename}:{frames[-1].lineno}" if frames else "unknown"
23
+ raw = f"{exc_type.__name__}:{location}"
24
+ return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16], location
25
+
26
+
27
+ def _get_connection() -> sqlite3.Connection:
28
+ _DB_PATH.parent.mkdir(parents=True, exist_ok=True)
29
+ conn = sqlite3.connect(_DB_PATH, timeout=5)
30
+ conn.execute("PRAGMA journal_mode=WAL")
31
+ conn.execute(
32
+ """
33
+ CREATE TABLE IF NOT EXISTS error_groups (
34
+ fingerprint TEXT PRIMARY KEY,
35
+ exc_type TEXT NOT NULL,
36
+ location TEXT NOT NULL,
37
+ first_seen REAL NOT NULL,
38
+ last_seen REAL NOT NULL,
39
+ last_sent REAL,
40
+ count_since_last_sent INTEGER NOT NULL DEFAULT 0,
41
+ total_count INTEGER NOT NULL DEFAULT 0,
42
+ rate_limit_seconds INTEGER,
43
+ muted INTEGER NOT NULL DEFAULT 0,
44
+ backoff_multiplier INTEGER NOT NULL DEFAULT 1
45
+ )
46
+ """
47
+ )
48
+ # ponytail: lazy migration for DBs created before these columns existed --
49
+ # ADD COLUMN has no "IF NOT EXISTS", so swallow the duplicate-column error
50
+ # on every subsequent call instead of tracking schema version.
51
+ for ddl in (
52
+ "ALTER TABLE error_groups ADD COLUMN rate_limit_seconds INTEGER",
53
+ "ALTER TABLE error_groups ADD COLUMN muted INTEGER NOT NULL DEFAULT 0",
54
+ "ALTER TABLE error_groups ADD COLUMN backoff_multiplier INTEGER NOT NULL DEFAULT 1",
55
+ ):
56
+ try:
57
+ conn.execute(ddl)
58
+ except sqlite3.OperationalError:
59
+ pass
60
+ return conn
61
+
62
+
63
+ def _should_send(
64
+ fingerprint: str, exc_type_name: str, location: str, rate_limit_seconds: int
65
+ ) -> tuple[bool, int]:
66
+ now = time.time()
67
+ try:
68
+ conn = _get_connection()
69
+ try:
70
+ with conn:
71
+ row = conn.execute(
72
+ "SELECT last_sent, count_since_last_sent, muted, backoff_multiplier "
73
+ "FROM error_groups WHERE fingerprint = ?",
74
+ (fingerprint,),
75
+ ).fetchone()
76
+ muted = bool(row[2]) if row else False
77
+ multiplier = row[3] if row else 1
78
+ effective_window = rate_limit_seconds * multiplier
79
+ if not muted and (
80
+ row is None or row[0] is None or now - row[0] >= effective_window
81
+ ):
82
+ send, skipped = True, (row[1] if row else 0)
83
+ # Chronic (occurrences piled up while suppressed) doubles the
84
+ # backoff, capped at _MAX_BACKOFF_MULTIPLIER; a single fresh
85
+ # occurrence after a genuine quiet spell resets it to 1.
86
+ new_multiplier = (
87
+ min(multiplier * 2, _MAX_BACKOFF_MULTIPLIER) if skipped else 1
88
+ )
89
+ conn.execute(
90
+ """
91
+ INSERT INTO error_groups
92
+ (fingerprint, exc_type, location, first_seen, last_seen,
93
+ last_sent, count_since_last_sent, total_count,
94
+ rate_limit_seconds, backoff_multiplier)
95
+ VALUES (?, ?, ?, ?, ?, ?, 0, 1, ?, ?)
96
+ ON CONFLICT(fingerprint) DO UPDATE SET
97
+ last_seen = excluded.last_seen,
98
+ last_sent = excluded.last_sent,
99
+ count_since_last_sent = 0,
100
+ total_count = total_count + 1,
101
+ rate_limit_seconds = excluded.rate_limit_seconds,
102
+ backoff_multiplier = excluded.backoff_multiplier
103
+ """,
104
+ (
105
+ fingerprint,
106
+ exc_type_name,
107
+ location,
108
+ now,
109
+ now,
110
+ now,
111
+ rate_limit_seconds,
112
+ new_multiplier,
113
+ ),
114
+ )
115
+ else:
116
+ # Covers both "still rate-limited" and "muted" -- neither sends,
117
+ # both keep counting so an eventual unmute/window-expiry reports
118
+ # the accumulated skip count via count_since_last_sent.
119
+ send, skipped = False, 0
120
+ conn.execute(
121
+ """
122
+ UPDATE error_groups
123
+ SET last_seen = ?, count_since_last_sent = count_since_last_sent + 1,
124
+ total_count = total_count + 1, rate_limit_seconds = ?
125
+ WHERE fingerprint = ?
126
+ """,
127
+ (now, rate_limit_seconds, fingerprint),
128
+ )
129
+ conn.execute(
130
+ "DELETE FROM error_groups WHERE last_seen < ?",
131
+ (now - _RETENTION_SECONDS,),
132
+ )
133
+ return send, skipped
134
+ finally:
135
+ conn.close()
136
+ except (sqlite3.Error, OSError) as error:
137
+ # ponytail: dedup/rate-limit state must never block an alert -- fail
138
+ # open (send, as if this were the first occurrence) on any DB error.
139
+ print(
140
+ f"devalerts: dedup/rate-limit state error, sending anyway: {error}",
141
+ file=sys.stderr,
142
+ )
143
+ return True, 0
144
+
145
+
146
+ def _match_fingerprints(prefix: str) -> list[str]:
147
+ """Fingerprints starting with prefix, for CLI mute/unmute/clear commands."""
148
+ conn = _get_connection()
149
+ try:
150
+ rows = conn.execute(
151
+ "SELECT fingerprint FROM error_groups WHERE fingerprint LIKE ? ESCAPE '\\'",
152
+ (
153
+ prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
154
+ + "%",
155
+ ),
156
+ ).fetchall()
157
+ finally:
158
+ conn.close()
159
+ return [row[0] for row in rows]
160
+
161
+
162
+ def _set_muted(fingerprint: str, muted: bool) -> None:
163
+ conn = _get_connection()
164
+ try:
165
+ with conn:
166
+ conn.execute(
167
+ "UPDATE error_groups SET muted = ? WHERE fingerprint = ?",
168
+ (int(muted), fingerprint),
169
+ )
170
+ finally:
171
+ conn.close()
172
+
173
+
174
+ def _clear(fingerprint: str) -> None:
175
+ conn = _get_connection()
176
+ try:
177
+ with conn:
178
+ conn.execute(
179
+ "DELETE FROM error_groups WHERE fingerprint = ?", (fingerprint,)
180
+ )
181
+ finally:
182
+ conn.close()
183
+
184
+
185
+ def _clear_all() -> None:
186
+ conn = _get_connection()
187
+ try:
188
+ with conn:
189
+ conn.execute("DELETE FROM error_groups")
190
+ finally:
191
+ conn.close()