devalerts 0.1.4__tar.gz → 0.2.0__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.4 → devalerts-0.2.0}/PKG-INFO +130 -14
- devalerts-0.2.0/README.md +268 -0
- {devalerts-0.1.4 → devalerts-0.2.0}/pyproject.toml +15 -3
- {devalerts-0.1.4 → devalerts-0.2.0}/src/devalerts/__init__.py +61 -15
- {devalerts-0.1.4 → devalerts-0.2.0}/src/devalerts/_alert.py +16 -2
- devalerts-0.2.0/src/devalerts/_celery.py +45 -0
- devalerts-0.2.0/src/devalerts/_store.py +191 -0
- devalerts-0.2.0/src/devalerts/_telegram.py +72 -0
- devalerts-0.2.0/src/devalerts/cli.py +317 -0
- devalerts-0.1.4/README.md +0 -152
- devalerts-0.1.4/src/devalerts/_store.py +0 -90
- devalerts-0.1.4/src/devalerts/_telegram.py +0 -25
- devalerts-0.1.4/src/devalerts/cli.py +0 -154
- {devalerts-0.1.4 → devalerts-0.2.0}/LICENSE +0 -0
- {devalerts-0.1.4 → devalerts-0.2.0}/src/devalerts/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devalerts
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
@@ -28,8 +28,12 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
# devalerts
|
|
29
29
|
|
|
30
30
|
[](https://pypi.org/project/devalerts/)
|
|
31
|
+
[](https://pypi.org/project/devalerts/)
|
|
31
32
|
[](https://pypi.org/project/devalerts/)
|
|
32
33
|
[](LICENSE)
|
|
34
|
+
[](https://github.com/sslinNn/devalerts/actions/workflows/test.yml)
|
|
35
|
+
[](https://github.com/astral-sh/ruff)
|
|
36
|
+
[](https://mypy-lang.org/)
|
|
33
37
|
|
|
34
38
|
[Русская версия](README.ru.md)
|
|
35
39
|
|
|
@@ -73,7 +77,13 @@ chat instead of a log file nobody's watching.
|
|
|
73
77
|
2. Message your bot once (or add it to a group) so it's allowed to message you back.
|
|
74
78
|
3. Get your chat id — message [@userinfobot](https://t.me/userinfobot), or call
|
|
75
79
|
`https://api.telegram.org/bot<TOKEN>/getUpdates` after step 2 and read `message.chat.id`.
|
|
76
|
-
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:
|
|
77
87
|
|
|
78
88
|
```python
|
|
79
89
|
import devalerts
|
|
@@ -103,13 +113,48 @@ See what's grouped and what's currently rate-limited:
|
|
|
103
113
|
uv run devalerts dashboard
|
|
104
114
|
```
|
|
105
115
|
|
|
116
|
+

|
|
117
|
+
|
|
118
|
+
Pass `--json` for machine-readable output. Silence a noisy group without
|
|
119
|
+
touching code — the `ID` column accepts any unique prefix:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
uv run devalerts mute abc12345
|
|
123
|
+
uv run devalerts unmute abc12345
|
|
124
|
+
uv run devalerts clear abc12345 # or: devalerts clear --all
|
|
106
125
|
```
|
|
107
|
-
ID TYPE LOCATION LAST SEEN TOTAL STATUS
|
|
108
|
-
────────────────────────────────────────────────────────────────────────────────────
|
|
109
|
-
a1b2c3d4 ValueError app/orders.py:42 2m ago 14 ● limited
|
|
110
|
-
9f8e7d6c KeyError app/handlers/webhook.py:88 just now 1 ● sending
|
|
111
126
|
|
|
112
|
-
|
|
127
|
+
Unmuting resends the next occurrence with the accumulated skip count, same
|
|
128
|
+
as a rate-limit window expiring.
|
|
129
|
+
|
|
130
|
+
Error groups that keep piling up while suppressed are chronic: each such
|
|
131
|
+
resend doubles the effective `rate_limit_seconds` for that group (capped at
|
|
132
|
+
8x), so a crash loop backs itself off instead of paging you every window
|
|
133
|
+
forever. A group that goes quiet and reappears once resets to the base rate
|
|
134
|
+
immediately. The dashboard shows the active multiplier (`● sending ×4`).
|
|
135
|
+
|
|
136
|
+
## Context: hostname and tags
|
|
137
|
+
|
|
138
|
+
Every alert automatically includes the sending host, so you can tell which
|
|
139
|
+
process/server it came from when one bot serves several:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
devalerts.init(bot_token="...", chat_id=123456789, tags={"env": "production"})
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
🔴 ValueError: boom
|
|
147
|
+
🖥️ prod-web-2 (env=production)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Add ad-hoc tags to a single call — they override `init()`'s tags on a key
|
|
151
|
+
collision:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
devalerts.report(extra={"request_id": "abc123"})
|
|
155
|
+
|
|
156
|
+
@devalerts.capture(extra={"job": "nightly-sync"})
|
|
157
|
+
def run_job(): ...
|
|
113
158
|
```
|
|
114
159
|
|
|
115
160
|
## Manually reporting a caught exception
|
|
@@ -151,11 +196,30 @@ Only exceptions that actually escape as server errors get reported — routing
|
|
|
151
196
|
404s and raised `HTTPException`s are already turned into responses by the
|
|
152
197
|
framework before the middleware sees them.
|
|
153
198
|
|
|
154
|
-
##
|
|
199
|
+
## Celery
|
|
200
|
+
|
|
201
|
+
Same problem as ASGI apps: `init()`'s excepthook never sees exceptions raised
|
|
202
|
+
inside a task, because Celery catches them itself to record the task's
|
|
203
|
+
`FAILURE` state. Call `init_celery()` in addition to `init()`:
|
|
155
204
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
205
|
+
```python
|
|
206
|
+
devalerts.init(bot_token="...", chat_id=123456789)
|
|
207
|
+
devalerts.init_celery()
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
This connects to Celery's `task_failure` signal (fired once a task has
|
|
211
|
+
genuinely failed — retries exhausted or none configured, so retried tasks
|
|
212
|
+
don't spam an alert per attempt) and reports through the same
|
|
213
|
+
grouping/rate-limiting/redaction path as everything else, tagged with the
|
|
214
|
+
task name and id automatically. Requires Celery to already be installed in
|
|
215
|
+
the worker process — it's imported lazily, not a devalerts dependency.
|
|
216
|
+
|
|
217
|
+
## Why not Sentry?
|
|
218
|
+
|
|
219
|
+
If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
|
|
220
|
+
replacement. It's for the side project, internal tool, or small service that
|
|
221
|
+
doesn't have (and doesn't want) that infrastructure: no account to create, no
|
|
222
|
+
SDK to configure, no server to trust — just a bot token you already control.
|
|
159
223
|
|
|
160
224
|
| | devalerts | Sentry-style tracker |
|
|
161
225
|
|--------------------------|---------------------|-----------------------|
|
|
@@ -165,14 +229,66 @@ service that doesn't have (and doesn't want) that infrastructure yet:
|
|
|
165
229
|
| Grouping / rate limiting | yes, local SQLite | yes, server-side |
|
|
166
230
|
| Search, trends, releases | no | yes |
|
|
167
231
|
|
|
232
|
+
## FAQ
|
|
233
|
+
|
|
234
|
+
**Works with FastAPI / Starlette?**
|
|
235
|
+
Yes — use [`ASGIMiddleware`](#fastapi--starlette--any-asgi-app), since the
|
|
236
|
+
default excepthook never sees request errors.
|
|
237
|
+
|
|
238
|
+
**Works with Docker?**
|
|
239
|
+
Yes, nothing container-specific. Just make sure `~/.devalerts/` (the dedup
|
|
240
|
+
state file) is either writable inside the container or a mounted volume if
|
|
241
|
+
you want dedup state to survive restarts — it self-recreates otherwise.
|
|
242
|
+
|
|
243
|
+
**Works with threads?**
|
|
244
|
+
Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
|
|
245
|
+
|
|
246
|
+
**Works with Celery / background workers?**
|
|
247
|
+
Yes — call [`init_celery()`](#celery) in addition to `init()` to catch
|
|
248
|
+
exceptions raised inside tasks, which the excepthook alone won't see.
|
|
249
|
+
|
|
250
|
+
**Works on Windows / Linux / macOS?**
|
|
251
|
+
Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
|
|
252
|
+
paths.
|
|
253
|
+
|
|
254
|
+
## Privacy & Security
|
|
255
|
+
|
|
256
|
+
- The only network call devalerts makes is to `api.telegram.org` — no
|
|
257
|
+
telemetry, no analytics, nothing else phones home.
|
|
258
|
+
- No third-party server and no devalerts-run backend — messages go straight
|
|
259
|
+
from your process to your own Telegram bot.
|
|
260
|
+
- No accounts, no signup, no API key beyond the bot token you create and
|
|
261
|
+
control yourself.
|
|
262
|
+
- Basic secret redaction only (a few common token/key patterns) — do not
|
|
263
|
+
rely on this for sensitive production data; scrub what you can before it
|
|
264
|
+
ever reaches an exception message.
|
|
265
|
+
- If Telegram delivery fails after retrying, the alert (already redacted, if
|
|
266
|
+
`redact=True`) is appended to `~/.devalerts/failed.log` instead of being
|
|
267
|
+
dropped — clean it up like any other local log file.
|
|
268
|
+
|
|
168
269
|
## What this does NOT do (by design)
|
|
169
270
|
|
|
170
271
|
- Grouping/rate limiting is local and in-process only (SQLite file, no
|
|
171
272
|
server) — the dashboard is a CLI table, not a web UI.
|
|
172
273
|
- No backend, no accounts — each user runs their own bot.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
274
|
+
|
|
275
|
+
## Roadmap
|
|
276
|
+
|
|
277
|
+
- Web dashboard (hosted, optional — the local CLI dashboard stays either way)
|
|
278
|
+
- Slack delivery
|
|
279
|
+
- Discord delivery
|
|
280
|
+
- Email delivery
|
|
281
|
+
|
|
282
|
+
## Development
|
|
283
|
+
|
|
284
|
+
uv sync --group dev
|
|
285
|
+
uv run pre-commit install
|
|
286
|
+
|
|
287
|
+
`pre-commit` runs `ruff check`, `ruff format`, and `mypy` before each commit.
|
|
288
|
+
Run the full check manually with:
|
|
289
|
+
|
|
290
|
+
uv run pytest
|
|
291
|
+
uv run pre-commit run --all-files
|
|
176
292
|
|
|
177
293
|
## License
|
|
178
294
|
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# devalerts
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/devalerts/)
|
|
4
|
+
[](https://pypi.org/project/devalerts/)
|
|
5
|
+
[](https://pypi.org/project/devalerts/)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://github.com/sslinNn/devalerts/actions/workflows/test.yml)
|
|
8
|
+
[](https://github.com/astral-sh/ruff)
|
|
9
|
+
[](https://mypy-lang.org/)
|
|
10
|
+
|
|
11
|
+
[Русская версия](README.ru.md)
|
|
12
|
+
|
|
13
|
+
Send unhandled Python exceptions straight to a Telegram chat — the moment
|
|
14
|
+
they happen, on your phone. No backend, no account, no database — just your
|
|
15
|
+
own bot token.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import devalerts
|
|
19
|
+
|
|
20
|
+
devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That's the whole setup. Two minutes with [@BotFather](https://t.me/BotFather)
|
|
24
|
+
and every unhandled crash — including ones raised in threads — lands in your
|
|
25
|
+
chat instead of a log file nobody's watching.
|
|
26
|
+
|
|
27
|
+
## Why devalerts
|
|
28
|
+
|
|
29
|
+
- **Zero infrastructure.** No SaaS signup, no ingestion server, no API key
|
|
30
|
+
to manage beyond your own Telegram bot token. State lives in a local
|
|
31
|
+
SQLite file you already own.
|
|
32
|
+
- **One line to install, one line to wire up.** `init()` installs the hook
|
|
33
|
+
and gets out of the way.
|
|
34
|
+
- **Not spam.** Errors are grouped by fingerprint and rate-limited per
|
|
35
|
+
group, so a crash loop sends one message, not a thousand.
|
|
36
|
+
- **Framework-aware.** Ships an ASGI middleware for FastAPI/Starlette apps,
|
|
37
|
+
where the default excepthook would never even see a request error.
|
|
38
|
+
- **Small and typed.** No dependencies, ships `py.typed`, ~450 lines total —
|
|
39
|
+
short enough to read in one sitting before you trust it with your errors.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
uv add devalerts
|
|
44
|
+
|
|
45
|
+
(or `pip install devalerts` if you're not using uv)
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
1. Create a bot with [@BotFather](https://t.me/BotFather) and get its token.
|
|
50
|
+
2. Message your bot once (or add it to a group) so it's allowed to message you back.
|
|
51
|
+
3. Get your chat id — message [@userinfobot](https://t.me/userinfobot), or call
|
|
52
|
+
`https://api.telegram.org/bot<TOKEN>/getUpdates` after step 2 and read `message.chat.id`.
|
|
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:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
import devalerts
|
|
63
|
+
|
|
64
|
+
devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
That's it — any unhandled exception (including ones raised in threads) now
|
|
68
|
+
also lands in your Telegram chat.
|
|
69
|
+
|
|
70
|
+
## Grouping, rate limiting, and the dashboard
|
|
71
|
+
|
|
72
|
+
Exceptions are grouped by fingerprint (exception type + file + line where it
|
|
73
|
+
was raised) in a local SQLite file (`~/.devalerts/state.db`). Each group
|
|
74
|
+
sends at most one Telegram message per `rate_limit_seconds` (default 300);
|
|
75
|
+
repeats inside that window are counted but not sent, and the next message
|
|
76
|
+
for that group says how many were skipped. Old groups (untouched for 7 days)
|
|
77
|
+
are pruned automatically. Configure the window via `init()`:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
devalerts.init(bot_token="...", chat_id=123456789, rate_limit_seconds=60)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
See what's grouped and what's currently rate-limited:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
uv run devalerts dashboard
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+

|
|
90
|
+
|
|
91
|
+
Pass `--json` for machine-readable output. Silence a noisy group without
|
|
92
|
+
touching code — the `ID` column accepts any unique prefix:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
uv run devalerts mute abc12345
|
|
96
|
+
uv run devalerts unmute abc12345
|
|
97
|
+
uv run devalerts clear abc12345 # or: devalerts clear --all
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Unmuting resends the next occurrence with the accumulated skip count, same
|
|
101
|
+
as a rate-limit window expiring.
|
|
102
|
+
|
|
103
|
+
Error groups that keep piling up while suppressed are chronic: each such
|
|
104
|
+
resend doubles the effective `rate_limit_seconds` for that group (capped at
|
|
105
|
+
8x), so a crash loop backs itself off instead of paging you every window
|
|
106
|
+
forever. A group that goes quiet and reappears once resets to the base rate
|
|
107
|
+
immediately. The dashboard shows the active multiplier (`● sending ×4`).
|
|
108
|
+
|
|
109
|
+
## Context: hostname and tags
|
|
110
|
+
|
|
111
|
+
Every alert automatically includes the sending host, so you can tell which
|
|
112
|
+
process/server it came from when one bot serves several:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
devalerts.init(bot_token="...", chat_id=123456789, tags={"env": "production"})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
🔴 ValueError: boom
|
|
120
|
+
🖥️ prod-web-2 (env=production)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Add ad-hoc tags to a single call — they override `init()`'s tags on a key
|
|
124
|
+
collision:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
devalerts.report(extra={"request_id": "abc123"})
|
|
128
|
+
|
|
129
|
+
@devalerts.capture(extra={"job": "nightly-sync"})
|
|
130
|
+
def run_job(): ...
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Manually reporting a caught exception
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
try:
|
|
137
|
+
risky_call()
|
|
138
|
+
except Exception:
|
|
139
|
+
devalerts.report() # sends the currently-handled exception
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
or:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
with devalerts.capture():
|
|
146
|
+
risky_call() # reports on exception, then re-raises
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`capture` also works as a decorator, so you don't need to touch a function's
|
|
150
|
+
body at all:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
@devalerts.capture()
|
|
154
|
+
def risky_call():
|
|
155
|
+
...
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## FastAPI / Starlette / any ASGI app
|
|
159
|
+
|
|
160
|
+
`init()`'s excepthook won't see request errors — the framework already catches
|
|
161
|
+
them internally to return a 500 response, so nothing "unhandled" ever reaches
|
|
162
|
+
the process. Use the ASGI middleware instead:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
app.add_middleware(devalerts.ASGIMiddleware)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Only exceptions that actually escape as server errors get reported — routing
|
|
169
|
+
404s and raised `HTTPException`s are already turned into responses by the
|
|
170
|
+
framework before the middleware sees them.
|
|
171
|
+
|
|
172
|
+
## Celery
|
|
173
|
+
|
|
174
|
+
Same problem as ASGI apps: `init()`'s excepthook never sees exceptions raised
|
|
175
|
+
inside a task, because Celery catches them itself to record the task's
|
|
176
|
+
`FAILURE` state. Call `init_celery()` in addition to `init()`:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
devalerts.init(bot_token="...", chat_id=123456789)
|
|
180
|
+
devalerts.init_celery()
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
This connects to Celery's `task_failure` signal (fired once a task has
|
|
184
|
+
genuinely failed — retries exhausted or none configured, so retried tasks
|
|
185
|
+
don't spam an alert per attempt) and reports through the same
|
|
186
|
+
grouping/rate-limiting/redaction path as everything else, tagged with the
|
|
187
|
+
task name and id automatically. Requires Celery to already be installed in
|
|
188
|
+
the worker process — it's imported lazily, not a devalerts dependency.
|
|
189
|
+
|
|
190
|
+
## Why not Sentry?
|
|
191
|
+
|
|
192
|
+
If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
|
|
193
|
+
replacement. It's for the side project, internal tool, or small service that
|
|
194
|
+
doesn't have (and doesn't want) that infrastructure: no account to create, no
|
|
195
|
+
SDK to configure, no server to trust — just a bot token you already control.
|
|
196
|
+
|
|
197
|
+
| | devalerts | Sentry-style tracker |
|
|
198
|
+
|--------------------------|---------------------|-----------------------|
|
|
199
|
+
| Setup | one bot token | account + project + SDK config |
|
|
200
|
+
| Backend | none — Telegram only | hosted or self-hosted service |
|
|
201
|
+
| Where alerts land | your Telegram chat | a web dashboard |
|
|
202
|
+
| Grouping / rate limiting | yes, local SQLite | yes, server-side |
|
|
203
|
+
| Search, trends, releases | no | yes |
|
|
204
|
+
|
|
205
|
+
## FAQ
|
|
206
|
+
|
|
207
|
+
**Works with FastAPI / Starlette?**
|
|
208
|
+
Yes — use [`ASGIMiddleware`](#fastapi--starlette--any-asgi-app), since the
|
|
209
|
+
default excepthook never sees request errors.
|
|
210
|
+
|
|
211
|
+
**Works with Docker?**
|
|
212
|
+
Yes, nothing container-specific. Just make sure `~/.devalerts/` (the dedup
|
|
213
|
+
state file) is either writable inside the container or a mounted volume if
|
|
214
|
+
you want dedup state to survive restarts — it self-recreates otherwise.
|
|
215
|
+
|
|
216
|
+
**Works with threads?**
|
|
217
|
+
Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
|
|
218
|
+
|
|
219
|
+
**Works with Celery / background workers?**
|
|
220
|
+
Yes — call [`init_celery()`](#celery) in addition to `init()` to catch
|
|
221
|
+
exceptions raised inside tasks, which the excepthook alone won't see.
|
|
222
|
+
|
|
223
|
+
**Works on Windows / Linux / macOS?**
|
|
224
|
+
Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
|
|
225
|
+
paths.
|
|
226
|
+
|
|
227
|
+
## Privacy & Security
|
|
228
|
+
|
|
229
|
+
- The only network call devalerts makes is to `api.telegram.org` — no
|
|
230
|
+
telemetry, no analytics, nothing else phones home.
|
|
231
|
+
- No third-party server and no devalerts-run backend — messages go straight
|
|
232
|
+
from your process to your own Telegram bot.
|
|
233
|
+
- No accounts, no signup, no API key beyond the bot token you create and
|
|
234
|
+
control yourself.
|
|
235
|
+
- Basic secret redaction only (a few common token/key patterns) — do not
|
|
236
|
+
rely on this for sensitive production data; scrub what you can before it
|
|
237
|
+
ever reaches an exception message.
|
|
238
|
+
- If Telegram delivery fails after retrying, the alert (already redacted, if
|
|
239
|
+
`redact=True`) is appended to `~/.devalerts/failed.log` instead of being
|
|
240
|
+
dropped — clean it up like any other local log file.
|
|
241
|
+
|
|
242
|
+
## What this does NOT do (by design)
|
|
243
|
+
|
|
244
|
+
- Grouping/rate limiting is local and in-process only (SQLite file, no
|
|
245
|
+
server) — the dashboard is a CLI table, not a web UI.
|
|
246
|
+
- No backend, no accounts — each user runs their own bot.
|
|
247
|
+
|
|
248
|
+
## Roadmap
|
|
249
|
+
|
|
250
|
+
- Web dashboard (hosted, optional — the local CLI dashboard stays either way)
|
|
251
|
+
- Slack delivery
|
|
252
|
+
- Discord delivery
|
|
253
|
+
- Email delivery
|
|
254
|
+
|
|
255
|
+
## Development
|
|
256
|
+
|
|
257
|
+
uv sync --group dev
|
|
258
|
+
uv run pre-commit install
|
|
259
|
+
|
|
260
|
+
`pre-commit` runs `ruff check`, `ruff format`, and `mypy` before each commit.
|
|
261
|
+
Run the full check manually with:
|
|
262
|
+
|
|
263
|
+
uv run pytest
|
|
264
|
+
uv run pre-commit run --all-files
|
|
265
|
+
|
|
266
|
+
## License
|
|
267
|
+
|
|
268
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "devalerts"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
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"
|
|
@@ -43,8 +43,20 @@ Repository = "https://github.com/sslinNn/devalerts"
|
|
|
43
43
|
devalerts = "devalerts.cli:main"
|
|
44
44
|
|
|
45
45
|
[build-system]
|
|
46
|
-
requires = ["uv_build>=0.10.2,<0.
|
|
46
|
+
requires = ["uv_build>=0.10.2,<0.12.0"]
|
|
47
47
|
build-backend = "uv_build"
|
|
48
48
|
|
|
49
49
|
[dependency-groups]
|
|
50
|
-
dev = [
|
|
50
|
+
dev = [
|
|
51
|
+
"celery>=5.3.0",
|
|
52
|
+
"mypy>=1.19.1",
|
|
53
|
+
"pre-commit>=4.3.0",
|
|
54
|
+
"pytest>=8.4.2",
|
|
55
|
+
"ruff>=0.15.22",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
target-version = "py39"
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint]
|
|
62
|
+
extend-select = ["BLE"]
|
|
@@ -5,29 +5,61 @@ from __future__ import annotations
|
|
|
5
5
|
import contextlib
|
|
6
6
|
import sys
|
|
7
7
|
import threading
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Callable, Literal, Optional, TypedDict
|
|
8
10
|
|
|
9
11
|
from ._alert import _format_alert, _redact
|
|
12
|
+
from ._celery import init_celery
|
|
10
13
|
from ._store import _DEFAULT_RATE_LIMIT_SECONDS, _fingerprint, _should_send
|
|
11
14
|
from ._telegram import _send_telegram_message
|
|
12
15
|
|
|
13
|
-
__all__: list[str] = ["init", "report", "capture", "ASGIMiddleware"]
|
|
16
|
+
__all__: list[str] = ["init", "report", "capture", "ASGIMiddleware", "init_celery"]
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
_ExceptHook = Callable[
|
|
19
|
+
[type[BaseException], BaseException, Optional[TracebackType]], object
|
|
20
|
+
]
|
|
21
|
+
_ThreadingExceptHook = Callable[[threading.ExceptHookArgs], object]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _State(TypedDict):
|
|
25
|
+
bot_token: str | None
|
|
26
|
+
chat_id: int | str | None
|
|
27
|
+
redact: bool
|
|
28
|
+
rate_limit_seconds: int
|
|
29
|
+
tags: dict[str, str]
|
|
30
|
+
# Seeded with the real default hooks below (never None) -- _excepthook/
|
|
31
|
+
# _threading_excepthook can only ever fire after init() has replaced
|
|
32
|
+
# sys.excepthook/threading.excepthook, by which point these are always
|
|
33
|
+
# set, so keeping them non-Optional avoids an unreachable None check.
|
|
34
|
+
prev_excepthook: _ExceptHook
|
|
35
|
+
prev_threading_excepthook: _ThreadingExceptHook
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_state: _State = {
|
|
16
39
|
"bot_token": None,
|
|
17
40
|
"chat_id": None,
|
|
18
41
|
"redact": True,
|
|
19
42
|
"rate_limit_seconds": _DEFAULT_RATE_LIMIT_SECONDS,
|
|
20
|
-
"
|
|
21
|
-
"
|
|
43
|
+
"tags": {},
|
|
44
|
+
"prev_excepthook": sys.excepthook,
|
|
45
|
+
"prev_threading_excepthook": threading.excepthook,
|
|
22
46
|
}
|
|
23
47
|
|
|
24
48
|
|
|
25
|
-
def _send_exception(
|
|
49
|
+
def _send_exception(
|
|
50
|
+
exc_type, exc_value, tb, extra: dict[str, str] | None = None
|
|
51
|
+
) -> None:
|
|
52
|
+
if _state["bot_token"] is None or _state["chat_id"] is None:
|
|
53
|
+
print("devalerts: init() was not called, dropping alert", file=sys.stderr)
|
|
54
|
+
return
|
|
26
55
|
fingerprint, location = _fingerprint(exc_type, tb)
|
|
27
|
-
send, skipped = _should_send(
|
|
56
|
+
send, skipped = _should_send(
|
|
57
|
+
fingerprint, exc_type.__name__, location, _state["rate_limit_seconds"]
|
|
58
|
+
)
|
|
28
59
|
if not send:
|
|
29
60
|
return
|
|
30
|
-
|
|
61
|
+
tags = {**_state["tags"], **(extra or {})}
|
|
62
|
+
message = _format_alert(exc_type, exc_value, tb, skipped=skipped, tags=tags)
|
|
31
63
|
if _state["redact"]:
|
|
32
64
|
message = _redact(message)
|
|
33
65
|
_send_telegram_message(_state["bot_token"], _state["chat_id"], message)
|
|
@@ -38,7 +70,10 @@ def _excepthook(exc_type, exc_value, tb) -> None:
|
|
|
38
70
|
try:
|
|
39
71
|
_send_exception(exc_type, exc_value, tb)
|
|
40
72
|
except Exception as error: # noqa: BLE001 - crash handler must never raise
|
|
41
|
-
print(
|
|
73
|
+
print(
|
|
74
|
+
f"devalerts: internal error while sending alert: {error}",
|
|
75
|
+
file=sys.stderr,
|
|
76
|
+
)
|
|
42
77
|
_state["prev_excepthook"](exc_type, exc_value, tb)
|
|
43
78
|
|
|
44
79
|
|
|
@@ -46,22 +81,26 @@ def _threading_excepthook(args) -> None:
|
|
|
46
81
|
try:
|
|
47
82
|
_send_exception(args.exc_type, args.exc_value, args.exc_traceback)
|
|
48
83
|
except Exception as error: # noqa: BLE001
|
|
49
|
-
print(
|
|
84
|
+
print(
|
|
85
|
+
f"devalerts: internal error while sending alert: {error}", file=sys.stderr
|
|
86
|
+
)
|
|
50
87
|
_state["prev_threading_excepthook"](args)
|
|
51
88
|
|
|
52
89
|
|
|
53
90
|
def init(
|
|
54
91
|
bot_token: str,
|
|
55
|
-
chat_id,
|
|
92
|
+
chat_id: int | str,
|
|
56
93
|
*,
|
|
57
94
|
redact: bool = True,
|
|
58
95
|
rate_limit_seconds: int = _DEFAULT_RATE_LIMIT_SECONDS,
|
|
96
|
+
tags: dict[str, str] | None = None,
|
|
59
97
|
) -> None:
|
|
60
98
|
"""Install a global exception hook that sends unhandled exceptions to Telegram."""
|
|
61
99
|
_state["bot_token"] = bot_token
|
|
62
100
|
_state["chat_id"] = chat_id
|
|
63
101
|
_state["redact"] = redact
|
|
64
102
|
_state["rate_limit_seconds"] = rate_limit_seconds
|
|
103
|
+
_state["tags"] = tags or {}
|
|
65
104
|
# ponytail: guard against calling init() twice capturing our own hook as
|
|
66
105
|
# "previous" -- that would make _excepthook chain to itself and recurse
|
|
67
106
|
# forever on the next crash, violating "must never raise".
|
|
@@ -73,15 +112,19 @@ def init(
|
|
|
73
112
|
threading.excepthook = _threading_excepthook
|
|
74
113
|
|
|
75
114
|
|
|
76
|
-
def report(
|
|
115
|
+
def report(
|
|
116
|
+
exc: BaseException | None = None, *, extra: dict[str, str] | None = None
|
|
117
|
+
) -> None:
|
|
77
118
|
"""Manually send a caught exception to Telegram."""
|
|
78
119
|
if exc is None:
|
|
79
120
|
exc_type, exc_value, tb = sys.exc_info()
|
|
80
121
|
if exc_type is None:
|
|
81
|
-
raise RuntimeError(
|
|
122
|
+
raise RuntimeError(
|
|
123
|
+
"report() requires an active exception or an exc argument"
|
|
124
|
+
)
|
|
82
125
|
else:
|
|
83
126
|
exc_type, exc_value, tb = type(exc), exc, exc.__traceback__
|
|
84
|
-
_send_exception(exc_type, exc_value, tb)
|
|
127
|
+
_send_exception(exc_type, exc_value, tb, extra=extra)
|
|
85
128
|
|
|
86
129
|
|
|
87
130
|
class capture(contextlib.ContextDecorator):
|
|
@@ -89,12 +132,15 @@ class capture(contextlib.ContextDecorator):
|
|
|
89
132
|
function, then re-raise it. Use ``@capture()`` on a function instead of wrapping
|
|
90
133
|
its body in a manual ``try/except`` or ``with`` block."""
|
|
91
134
|
|
|
135
|
+
def __init__(self, *, extra: dict[str, str] | None = None) -> None:
|
|
136
|
+
self._extra = extra
|
|
137
|
+
|
|
92
138
|
def __enter__(self) -> "capture":
|
|
93
139
|
return self
|
|
94
140
|
|
|
95
|
-
def __exit__(self, exc_type, exc_value, tb) ->
|
|
141
|
+
def __exit__(self, exc_type, exc_value, tb) -> Literal[False]:
|
|
96
142
|
if exc_type is not None:
|
|
97
|
-
_send_exception(exc_type, exc_value, tb)
|
|
143
|
+
_send_exception(exc_type, exc_value, tb, extra=self._extra)
|
|
98
144
|
return False
|
|
99
145
|
|
|
100
146
|
|
|
@@ -3,13 +3,27 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
|
+
import socket
|
|
6
7
|
import traceback
|
|
7
8
|
|
|
8
9
|
_MAX_MESSAGE_LENGTH = 4096
|
|
9
10
|
|
|
10
11
|
|
|
11
|
-
def
|
|
12
|
-
|
|
12
|
+
def _format_context(tags: dict[str, str] | None) -> str:
|
|
13
|
+
try:
|
|
14
|
+
hostname = socket.gethostname()
|
|
15
|
+
except OSError:
|
|
16
|
+
hostname = "unknown-host"
|
|
17
|
+
line = f"🖥️ {hostname}"
|
|
18
|
+
if tags:
|
|
19
|
+
line += " (" + ", ".join(f"{key}={value}" for key, value in tags.items()) + ")"
|
|
20
|
+
return line
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _format_alert(
|
|
24
|
+
exc_type, exc_value, tb, skipped: int = 0, tags: dict[str, str] | None = None
|
|
25
|
+
) -> str:
|
|
26
|
+
header = f"\U0001f534 {exc_type.__name__}: {exc_value}\n{_format_context(tags)}"
|
|
13
27
|
if skipped:
|
|
14
28
|
header += f"\n⚠️ Повторилась ещё {skipped} раз(а) с последнего алерта"
|
|
15
29
|
body = "".join(traceback.format_exception(exc_type, exc_value, tb))
|