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.
- {devalerts-0.1.5 → devalerts-0.2.1}/PKG-INFO +77 -4
- {devalerts-0.1.5 → devalerts-0.2.1}/README.md +76 -3
- {devalerts-0.1.5 → devalerts-0.2.1}/pyproject.toml +2 -1
- {devalerts-0.1.5 → devalerts-0.2.1}/src/devalerts/__init__.py +19 -6
- devalerts-0.2.1/src/devalerts/_alert.py +71 -0
- devalerts-0.2.1/src/devalerts/_celery.py +45 -0
- devalerts-0.2.1/src/devalerts/_store.py +191 -0
- devalerts-0.2.1/src/devalerts/_telegram.py +74 -0
- devalerts-0.2.1/src/devalerts/cli.py +317 -0
- devalerts-0.1.5/src/devalerts/_alert.py +0 -44
- devalerts-0.1.5/src/devalerts/_store.py +0 -98
- devalerts-0.1.5/src/devalerts/_telegram.py +0 -25
- devalerts-0.1.5/src/devalerts/cli.py +0 -156
- {devalerts-0.1.5 → devalerts-0.2.1}/LICENSE +0 -0
- {devalerts-0.1.5 → devalerts-0.2.1}/src/devalerts/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devalerts
|
|
3
|
-
Version: 0.1
|
|
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.
|
|
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
|

|
|
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
|
|
182
|
-
|
|
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.
|
|
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
|

|
|
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
|
|
155
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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("&", "&").replace("<", "<").replace(">", ">")
|
|
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()
|