devalerts 0.1.3__tar.gz → 0.1.4__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/LICENSE +21 -0
- devalerts-0.1.4/PKG-INFO +179 -0
- devalerts-0.1.4/README.md +152 -0
- devalerts-0.1.4/pyproject.toml +50 -0
- devalerts-0.1.4/src/devalerts/__init__.py +129 -0
- devalerts-0.1.4/src/devalerts/_alert.py +44 -0
- devalerts-0.1.4/src/devalerts/_store.py +90 -0
- devalerts-0.1.4/src/devalerts/_telegram.py +25 -0
- devalerts-0.1.4/src/devalerts/cli.py +154 -0
- devalerts-0.1.3/PKG-INFO +0 -105
- devalerts-0.1.3/README.md +0 -96
- devalerts-0.1.3/pyproject.toml +0 -20
- devalerts-0.1.3/src/devalerts/__init__.py +0 -270
- devalerts-0.1.3/src/devalerts/cli.py +0 -60
- {devalerts-0.1.3 → devalerts-0.1.4}/src/devalerts/py.typed +0 -0
devalerts-0.1.4/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sslinNn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
devalerts-0.1.4/PKG-INFO
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: devalerts
|
|
3
|
+
Version: 0.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
|
+
Keywords: telegram,error-tracking,exception-monitoring,alerts,logging,monitoring,asgi
|
|
6
|
+
Author: sslinNn
|
|
7
|
+
Author-email: sslinNn <morison1991@mail.ru>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Project-URL: Homepage, https://github.com/sslinNn/devalerts
|
|
24
|
+
Project-URL: Repository, https://github.com/sslinNn/devalerts
|
|
25
|
+
Project-URL: Bug Tracker, https://github.com/sslinNn/devalerts/issues
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# devalerts
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/devalerts/)
|
|
31
|
+
[](https://pypi.org/project/devalerts/)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
|
|
34
|
+
[Русская версия](README.ru.md)
|
|
35
|
+
|
|
36
|
+
Send unhandled Python exceptions straight to a Telegram chat — the moment
|
|
37
|
+
they happen, on your phone. No backend, no account, no database — just your
|
|
38
|
+
own bot token.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import devalerts
|
|
42
|
+
|
|
43
|
+
devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
That's the whole setup. Two minutes with [@BotFather](https://t.me/BotFather)
|
|
47
|
+
and every unhandled crash — including ones raised in threads — lands in your
|
|
48
|
+
chat instead of a log file nobody's watching.
|
|
49
|
+
|
|
50
|
+
## Why devalerts
|
|
51
|
+
|
|
52
|
+
- **Zero infrastructure.** No SaaS signup, no ingestion server, no API key
|
|
53
|
+
to manage beyond your own Telegram bot token. State lives in a local
|
|
54
|
+
SQLite file you already own.
|
|
55
|
+
- **One line to install, one line to wire up.** `init()` installs the hook
|
|
56
|
+
and gets out of the way.
|
|
57
|
+
- **Not spam.** Errors are grouped by fingerprint and rate-limited per
|
|
58
|
+
group, so a crash loop sends one message, not a thousand.
|
|
59
|
+
- **Framework-aware.** Ships an ASGI middleware for FastAPI/Starlette apps,
|
|
60
|
+
where the default excepthook would never even see a request error.
|
|
61
|
+
- **Small and typed.** No dependencies, ships `py.typed`, ~450 lines total —
|
|
62
|
+
short enough to read in one sitting before you trust it with your errors.
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
uv add devalerts
|
|
67
|
+
|
|
68
|
+
(or `pip install devalerts` if you're not using uv)
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
1. Create a bot with [@BotFather](https://t.me/BotFather) and get its token.
|
|
73
|
+
2. Message your bot once (or add it to a group) so it's allowed to message you back.
|
|
74
|
+
3. Get your chat id — message [@userinfobot](https://t.me/userinfobot), or call
|
|
75
|
+
`https://api.telegram.org/bot<TOKEN>/getUpdates` after step 2 and read `message.chat.id`.
|
|
76
|
+
4. In your app, as early as possible:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
import devalerts
|
|
80
|
+
|
|
81
|
+
devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
That's it — any unhandled exception (including ones raised in threads) now
|
|
85
|
+
also lands in your Telegram chat.
|
|
86
|
+
|
|
87
|
+
## Grouping, rate limiting, and the dashboard
|
|
88
|
+
|
|
89
|
+
Exceptions are grouped by fingerprint (exception type + file + line where it
|
|
90
|
+
was raised) in a local SQLite file (`~/.devalerts/state.db`). Each group
|
|
91
|
+
sends at most one Telegram message per `rate_limit_seconds` (default 300);
|
|
92
|
+
repeats inside that window are counted but not sent, and the next message
|
|
93
|
+
for that group says how many were skipped. Old groups (untouched for 7 days)
|
|
94
|
+
are pruned automatically. Configure the window via `init()`:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
devalerts.init(bot_token="...", chat_id=123456789, rate_limit_seconds=60)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
See what's grouped and what's currently rate-limited:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
uv run devalerts dashboard
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```
|
|
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
|
+
|
|
112
|
+
2 error groups, 1 currently rate-limited.
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Manually reporting a caught exception
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
try:
|
|
119
|
+
risky_call()
|
|
120
|
+
except Exception:
|
|
121
|
+
devalerts.report() # sends the currently-handled exception
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
or:
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
with devalerts.capture():
|
|
128
|
+
risky_call() # reports on exception, then re-raises
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`capture` also works as a decorator, so you don't need to touch a function's
|
|
132
|
+
body at all:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
@devalerts.capture()
|
|
136
|
+
def risky_call():
|
|
137
|
+
...
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## FastAPI / Starlette / any ASGI app
|
|
141
|
+
|
|
142
|
+
`init()`'s excepthook won't see request errors — the framework already catches
|
|
143
|
+
them internally to return a 500 response, so nothing "unhandled" ever reaches
|
|
144
|
+
the process. Use the ASGI middleware instead:
|
|
145
|
+
|
|
146
|
+
```python
|
|
147
|
+
app.add_middleware(devalerts.ASGIMiddleware)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Only exceptions that actually escape as server errors get reported — routing
|
|
151
|
+
404s and raised `HTTPException`s are already turned into responses by the
|
|
152
|
+
framework before the middleware sees them.
|
|
153
|
+
|
|
154
|
+
## devalerts vs. a full error tracker
|
|
155
|
+
|
|
156
|
+
If you already run Sentry/Rollbar/etc., keep using it — this isn't a
|
|
157
|
+
replacement. devalerts is for the side project, internal tool, or small
|
|
158
|
+
service that doesn't have (and doesn't want) that infrastructure yet:
|
|
159
|
+
|
|
160
|
+
| | devalerts | Sentry-style tracker |
|
|
161
|
+
|--------------------------|---------------------|-----------------------|
|
|
162
|
+
| Setup | one bot token | account + project + SDK config |
|
|
163
|
+
| Backend | none — Telegram only | hosted or self-hosted service |
|
|
164
|
+
| Where alerts land | your Telegram chat | a web dashboard |
|
|
165
|
+
| Grouping / rate limiting | yes, local SQLite | yes, server-side |
|
|
166
|
+
| Search, trends, releases | no | yes |
|
|
167
|
+
|
|
168
|
+
## What this does NOT do (by design)
|
|
169
|
+
|
|
170
|
+
- Grouping/rate limiting is local and in-process only (SQLite file, no
|
|
171
|
+
server) — the dashboard is a CLI table, not a web UI.
|
|
172
|
+
- No backend, no accounts — each user runs their own bot.
|
|
173
|
+
- Basic secret redaction only (a few common token patterns) — do not rely
|
|
174
|
+
on this for sensitive production data.
|
|
175
|
+
- No automated test suite — verified manually during implementation only.
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# devalerts
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/devalerts/)
|
|
4
|
+
[](https://pypi.org/project/devalerts/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
|
|
7
|
+
[Русская версия](README.ru.md)
|
|
8
|
+
|
|
9
|
+
Send unhandled Python exceptions straight to a Telegram chat — the moment
|
|
10
|
+
they happen, on your phone. No backend, no account, no database — just your
|
|
11
|
+
own bot token.
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import devalerts
|
|
15
|
+
|
|
16
|
+
devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
That's the whole setup. Two minutes with [@BotFather](https://t.me/BotFather)
|
|
20
|
+
and every unhandled crash — including ones raised in threads — lands in your
|
|
21
|
+
chat instead of a log file nobody's watching.
|
|
22
|
+
|
|
23
|
+
## Why devalerts
|
|
24
|
+
|
|
25
|
+
- **Zero infrastructure.** No SaaS signup, no ingestion server, no API key
|
|
26
|
+
to manage beyond your own Telegram bot token. State lives in a local
|
|
27
|
+
SQLite file you already own.
|
|
28
|
+
- **One line to install, one line to wire up.** `init()` installs the hook
|
|
29
|
+
and gets out of the way.
|
|
30
|
+
- **Not spam.** Errors are grouped by fingerprint and rate-limited per
|
|
31
|
+
group, so a crash loop sends one message, not a thousand.
|
|
32
|
+
- **Framework-aware.** Ships an ASGI middleware for FastAPI/Starlette apps,
|
|
33
|
+
where the default excepthook would never even see a request error.
|
|
34
|
+
- **Small and typed.** No dependencies, ships `py.typed`, ~450 lines total —
|
|
35
|
+
short enough to read in one sitting before you trust it with your errors.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
uv add devalerts
|
|
40
|
+
|
|
41
|
+
(or `pip install devalerts` if you're not using uv)
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
1. Create a bot with [@BotFather](https://t.me/BotFather) and get its token.
|
|
46
|
+
2. Message your bot once (or add it to a group) so it's allowed to message you back.
|
|
47
|
+
3. Get your chat id — message [@userinfobot](https://t.me/userinfobot), or call
|
|
48
|
+
`https://api.telegram.org/bot<TOKEN>/getUpdates` after step 2 and read `message.chat.id`.
|
|
49
|
+
4. In your app, as early as possible:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import devalerts
|
|
53
|
+
|
|
54
|
+
devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That's it — any unhandled exception (including ones raised in threads) now
|
|
58
|
+
also lands in your Telegram chat.
|
|
59
|
+
|
|
60
|
+
## Grouping, rate limiting, and the dashboard
|
|
61
|
+
|
|
62
|
+
Exceptions are grouped by fingerprint (exception type + file + line where it
|
|
63
|
+
was raised) in a local SQLite file (`~/.devalerts/state.db`). Each group
|
|
64
|
+
sends at most one Telegram message per `rate_limit_seconds` (default 300);
|
|
65
|
+
repeats inside that window are counted but not sent, and the next message
|
|
66
|
+
for that group says how many were skipped. Old groups (untouched for 7 days)
|
|
67
|
+
are pruned automatically. Configure the window via `init()`:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
devalerts.init(bot_token="...", chat_id=123456789, rate_limit_seconds=60)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
See what's grouped and what's currently rate-limited:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
uv run devalerts dashboard
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
ID TYPE LOCATION LAST SEEN TOTAL STATUS
|
|
81
|
+
────────────────────────────────────────────────────────────────────────────────────
|
|
82
|
+
a1b2c3d4 ValueError app/orders.py:42 2m ago 14 ● limited
|
|
83
|
+
9f8e7d6c KeyError app/handlers/webhook.py:88 just now 1 ● sending
|
|
84
|
+
|
|
85
|
+
2 error groups, 1 currently rate-limited.
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Manually reporting a caught exception
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
try:
|
|
92
|
+
risky_call()
|
|
93
|
+
except Exception:
|
|
94
|
+
devalerts.report() # sends the currently-handled exception
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
or:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
with devalerts.capture():
|
|
101
|
+
risky_call() # reports on exception, then re-raises
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`capture` also works as a decorator, so you don't need to touch a function's
|
|
105
|
+
body at all:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
@devalerts.capture()
|
|
109
|
+
def risky_call():
|
|
110
|
+
...
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## FastAPI / Starlette / any ASGI app
|
|
114
|
+
|
|
115
|
+
`init()`'s excepthook won't see request errors — the framework already catches
|
|
116
|
+
them internally to return a 500 response, so nothing "unhandled" ever reaches
|
|
117
|
+
the process. Use the ASGI middleware instead:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
app.add_middleware(devalerts.ASGIMiddleware)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Only exceptions that actually escape as server errors get reported — routing
|
|
124
|
+
404s and raised `HTTPException`s are already turned into responses by the
|
|
125
|
+
framework before the middleware sees them.
|
|
126
|
+
|
|
127
|
+
## devalerts vs. a full error tracker
|
|
128
|
+
|
|
129
|
+
If you already run Sentry/Rollbar/etc., keep using it — this isn't a
|
|
130
|
+
replacement. devalerts is for the side project, internal tool, or small
|
|
131
|
+
service that doesn't have (and doesn't want) that infrastructure yet:
|
|
132
|
+
|
|
133
|
+
| | devalerts | Sentry-style tracker |
|
|
134
|
+
|--------------------------|---------------------|-----------------------|
|
|
135
|
+
| Setup | one bot token | account + project + SDK config |
|
|
136
|
+
| Backend | none — Telegram only | hosted or self-hosted service |
|
|
137
|
+
| Where alerts land | your Telegram chat | a web dashboard |
|
|
138
|
+
| Grouping / rate limiting | yes, local SQLite | yes, server-side |
|
|
139
|
+
| Search, trends, releases | no | yes |
|
|
140
|
+
|
|
141
|
+
## What this does NOT do (by design)
|
|
142
|
+
|
|
143
|
+
- Grouping/rate limiting is local and in-process only (SQLite file, no
|
|
144
|
+
server) — the dashboard is a CLI table, not a web UI.
|
|
145
|
+
- No backend, no accounts — each user runs their own bot.
|
|
146
|
+
- Basic secret redaction only (a few common token patterns) — do not rely
|
|
147
|
+
on this for sensitive production data.
|
|
148
|
+
- No automated test suite — verified manually during implementation only.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "devalerts"
|
|
3
|
+
version = "0.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
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "sslinNn", email = "morison1991@mail.ru" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = [
|
|
14
|
+
"telegram",
|
|
15
|
+
"error-tracking",
|
|
16
|
+
"exception-monitoring",
|
|
17
|
+
"alerts",
|
|
18
|
+
"logging",
|
|
19
|
+
"monitoring",
|
|
20
|
+
"asgi",
|
|
21
|
+
]
|
|
22
|
+
classifiers = [
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
27
|
+
"Programming Language :: Python :: 3.9",
|
|
28
|
+
"Programming Language :: Python :: 3.10",
|
|
29
|
+
"Programming Language :: Python :: 3.11",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Programming Language :: Python :: 3.13",
|
|
32
|
+
"Topic :: Software Development :: Debuggers",
|
|
33
|
+
"Topic :: System :: Monitoring",
|
|
34
|
+
"Typing :: Typed",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/sslinNn/devalerts"
|
|
39
|
+
Repository = "https://github.com/sslinNn/devalerts"
|
|
40
|
+
"Bug Tracker" = "https://github.com/sslinNn/devalerts/issues"
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
devalerts = "devalerts.cli:main"
|
|
44
|
+
|
|
45
|
+
[build-system]
|
|
46
|
+
requires = ["uv_build>=0.10.2,<0.11.0"]
|
|
47
|
+
build-backend = "uv_build"
|
|
48
|
+
|
|
49
|
+
[dependency-groups]
|
|
50
|
+
dev = []
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Send unhandled Python exceptions straight to a Telegram chat."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
from ._alert import _format_alert, _redact
|
|
10
|
+
from ._store import _DEFAULT_RATE_LIMIT_SECONDS, _fingerprint, _should_send
|
|
11
|
+
from ._telegram import _send_telegram_message
|
|
12
|
+
|
|
13
|
+
__all__: list[str] = ["init", "report", "capture", "ASGIMiddleware"]
|
|
14
|
+
|
|
15
|
+
_state = {
|
|
16
|
+
"bot_token": None,
|
|
17
|
+
"chat_id": None,
|
|
18
|
+
"redact": True,
|
|
19
|
+
"rate_limit_seconds": _DEFAULT_RATE_LIMIT_SECONDS,
|
|
20
|
+
"prev_excepthook": None,
|
|
21
|
+
"prev_threading_excepthook": None,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _send_exception(exc_type, exc_value, tb) -> None:
|
|
26
|
+
fingerprint, location = _fingerprint(exc_type, tb)
|
|
27
|
+
send, skipped = _should_send(fingerprint, exc_type.__name__, location, _state["rate_limit_seconds"])
|
|
28
|
+
if not send:
|
|
29
|
+
return
|
|
30
|
+
message = _format_alert(exc_type, exc_value, tb, skipped=skipped)
|
|
31
|
+
if _state["redact"]:
|
|
32
|
+
message = _redact(message)
|
|
33
|
+
_send_telegram_message(_state["bot_token"], _state["chat_id"], message)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _excepthook(exc_type, exc_value, tb) -> None:
|
|
37
|
+
if exc_type is not KeyboardInterrupt:
|
|
38
|
+
try:
|
|
39
|
+
_send_exception(exc_type, exc_value, tb)
|
|
40
|
+
except Exception as error: # noqa: BLE001 - crash handler must never raise
|
|
41
|
+
print(f"devalerts: internal error while sending alert: {error}", file=sys.stderr)
|
|
42
|
+
_state["prev_excepthook"](exc_type, exc_value, tb)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _threading_excepthook(args) -> None:
|
|
46
|
+
try:
|
|
47
|
+
_send_exception(args.exc_type, args.exc_value, args.exc_traceback)
|
|
48
|
+
except Exception as error: # noqa: BLE001
|
|
49
|
+
print(f"devalerts: internal error while sending alert: {error}", file=sys.stderr)
|
|
50
|
+
_state["prev_threading_excepthook"](args)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def init(
|
|
54
|
+
bot_token: str,
|
|
55
|
+
chat_id,
|
|
56
|
+
*,
|
|
57
|
+
redact: bool = True,
|
|
58
|
+
rate_limit_seconds: int = _DEFAULT_RATE_LIMIT_SECONDS,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Install a global exception hook that sends unhandled exceptions to Telegram."""
|
|
61
|
+
_state["bot_token"] = bot_token
|
|
62
|
+
_state["chat_id"] = chat_id
|
|
63
|
+
_state["redact"] = redact
|
|
64
|
+
_state["rate_limit_seconds"] = rate_limit_seconds
|
|
65
|
+
# ponytail: guard against calling init() twice capturing our own hook as
|
|
66
|
+
# "previous" -- that would make _excepthook chain to itself and recurse
|
|
67
|
+
# forever on the next crash, violating "must never raise".
|
|
68
|
+
if sys.excepthook is not _excepthook:
|
|
69
|
+
_state["prev_excepthook"] = sys.excepthook
|
|
70
|
+
if threading.excepthook is not _threading_excepthook:
|
|
71
|
+
_state["prev_threading_excepthook"] = threading.excepthook
|
|
72
|
+
sys.excepthook = _excepthook
|
|
73
|
+
threading.excepthook = _threading_excepthook
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def report(exc: BaseException | None = None) -> None:
|
|
77
|
+
"""Manually send a caught exception to Telegram."""
|
|
78
|
+
if exc is None:
|
|
79
|
+
exc_type, exc_value, tb = sys.exc_info()
|
|
80
|
+
if exc_type is None:
|
|
81
|
+
raise RuntimeError("report() requires an active exception or an exc argument")
|
|
82
|
+
else:
|
|
83
|
+
exc_type, exc_value, tb = type(exc), exc, exc.__traceback__
|
|
84
|
+
_send_exception(exc_type, exc_value, tb)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class capture(contextlib.ContextDecorator):
|
|
88
|
+
"""Context manager / decorator: report any exception raised inside the block or
|
|
89
|
+
function, then re-raise it. Use ``@capture()`` on a function instead of wrapping
|
|
90
|
+
its body in a manual ``try/except`` or ``with`` block."""
|
|
91
|
+
|
|
92
|
+
def __enter__(self) -> "capture":
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def __exit__(self, exc_type, exc_value, tb) -> bool:
|
|
96
|
+
if exc_type is not None:
|
|
97
|
+
_send_exception(exc_type, exc_value, tb)
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ASGIMiddleware:
|
|
102
|
+
"""ASGI middleware for FastAPI/Starlette (or any ASGI app): report any exception
|
|
103
|
+
that escapes a request, then re-raise it so the framework's own error handling
|
|
104
|
+
still runs unchanged.
|
|
105
|
+
|
|
106
|
+
Usage::
|
|
107
|
+
|
|
108
|
+
app.add_middleware(devalerts.ASGIMiddleware)
|
|
109
|
+
|
|
110
|
+
Only exceptions that actually reach here (unhandled server errors) get reported —
|
|
111
|
+
routing 404s and raised ``HTTPException``s are already turned into responses by
|
|
112
|
+
the framework before this middleware sees them, same as Sentry's ASGI integration.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(self, app) -> None:
|
|
116
|
+
self.app = app
|
|
117
|
+
|
|
118
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
119
|
+
try:
|
|
120
|
+
await self.app(scope, receive, send)
|
|
121
|
+
except Exception:
|
|
122
|
+
exc_type, exc_value, tb = sys.exc_info()
|
|
123
|
+
# ponytail: fire-and-forget in a thread -- _send_exception is a
|
|
124
|
+
# blocking network call; awaiting it here would stall the event
|
|
125
|
+
# loop (and the error response) for up to _TIMEOUT_SECONDS.
|
|
126
|
+
threading.Thread(
|
|
127
|
+
target=_send_exception, args=(exc_type, exc_value, tb), daemon=True
|
|
128
|
+
).start()
|
|
129
|
+
raise
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Alert message formatting and secret redaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import traceback
|
|
7
|
+
|
|
8
|
+
_MAX_MESSAGE_LENGTH = 4096
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _format_alert(exc_type, exc_value, tb, skipped: int = 0) -> str:
|
|
12
|
+
header = f"\U0001F534 {exc_type.__name__}: {exc_value}"
|
|
13
|
+
if skipped:
|
|
14
|
+
header += f"\n⚠️ Повторилась ещё {skipped} раз(а) с последнего алерта"
|
|
15
|
+
body = "".join(traceback.format_exception(exc_type, exc_value, tb))
|
|
16
|
+
message = f"{header}\n\n{body}"
|
|
17
|
+
if len(message) <= _MAX_MESSAGE_LENGTH:
|
|
18
|
+
return message
|
|
19
|
+
marker = "\n\n...(truncated)...\n"
|
|
20
|
+
# ponytail: header itself can exceed the limit if exc_value's str() is huge
|
|
21
|
+
# (e.g. a validation error echoing a large payload) — keep can go negative,
|
|
22
|
+
# so clamp it and hard-truncate the final result as a backstop guarantee.
|
|
23
|
+
keep = max(_MAX_MESSAGE_LENGTH - len(header) - len(marker), 0)
|
|
24
|
+
truncated = f"{header}{marker}{body[-keep:]}" if keep else header
|
|
25
|
+
return truncated[:_MAX_MESSAGE_LENGTH]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ponytail: fixed pattern list, not exhaustive — catches common
|
|
29
|
+
# token/key shapes only. Upgrade to entropy-based detection if
|
|
30
|
+
# real users report leaked secrets slipping through.
|
|
31
|
+
_REDACT_PATTERNS = [
|
|
32
|
+
(re.compile(r"AKIA[0-9A-Z]{16}"), "[REDACTED]"),
|
|
33
|
+
(re.compile(r"Bearer\s+[A-Za-z0-9\-_.]+", re.IGNORECASE), "Bearer [REDACTED]"),
|
|
34
|
+
(
|
|
35
|
+
re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[=:]\s*\S+"),
|
|
36
|
+
r"\1=[REDACTED]",
|
|
37
|
+
),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _redact(text: str) -> str:
|
|
42
|
+
for pattern, replacement in _REDACT_PATTERNS:
|
|
43
|
+
text = pattern.sub(replacement, text)
|
|
44
|
+
return text
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
|
|
16
|
+
|
|
17
|
+
def _fingerprint(exc_type, tb) -> tuple[str, str]:
|
|
18
|
+
frames = traceback.extract_tb(tb)
|
|
19
|
+
location = f"{frames[-1].filename}:{frames[-1].lineno}" if frames else "unknown"
|
|
20
|
+
raw = f"{exc_type.__name__}:{location}"
|
|
21
|
+
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16], location
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_connection() -> sqlite3.Connection:
|
|
25
|
+
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
conn = sqlite3.connect(_DB_PATH, timeout=5)
|
|
27
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
28
|
+
conn.execute(
|
|
29
|
+
"""
|
|
30
|
+
CREATE TABLE IF NOT EXISTS error_groups (
|
|
31
|
+
fingerprint TEXT PRIMARY KEY,
|
|
32
|
+
exc_type TEXT NOT NULL,
|
|
33
|
+
location TEXT NOT NULL,
|
|
34
|
+
first_seen REAL NOT NULL,
|
|
35
|
+
last_seen REAL NOT NULL,
|
|
36
|
+
last_sent REAL,
|
|
37
|
+
count_since_last_sent INTEGER NOT NULL DEFAULT 0,
|
|
38
|
+
total_count INTEGER NOT NULL DEFAULT 0
|
|
39
|
+
)
|
|
40
|
+
"""
|
|
41
|
+
)
|
|
42
|
+
return conn
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _should_send(fingerprint: str, exc_type_name: str, location: str, rate_limit_seconds: int) -> tuple[bool, int]:
|
|
46
|
+
now = time.time()
|
|
47
|
+
try:
|
|
48
|
+
conn = _get_connection()
|
|
49
|
+
try:
|
|
50
|
+
with conn:
|
|
51
|
+
row = conn.execute(
|
|
52
|
+
"SELECT last_sent, count_since_last_sent FROM error_groups WHERE fingerprint = ?",
|
|
53
|
+
(fingerprint,),
|
|
54
|
+
).fetchone()
|
|
55
|
+
if row is None or row[0] is None or now - row[0] >= rate_limit_seconds:
|
|
56
|
+
send, skipped = True, (row[1] if row else 0)
|
|
57
|
+
conn.execute(
|
|
58
|
+
"""
|
|
59
|
+
INSERT INTO error_groups
|
|
60
|
+
(fingerprint, exc_type, location, first_seen, last_seen,
|
|
61
|
+
last_sent, count_since_last_sent, total_count)
|
|
62
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, 1)
|
|
63
|
+
ON CONFLICT(fingerprint) DO UPDATE SET
|
|
64
|
+
last_seen = excluded.last_seen,
|
|
65
|
+
last_sent = excluded.last_sent,
|
|
66
|
+
count_since_last_sent = 0,
|
|
67
|
+
total_count = total_count + 1
|
|
68
|
+
""",
|
|
69
|
+
(fingerprint, exc_type_name, location, now, now, now),
|
|
70
|
+
)
|
|
71
|
+
else:
|
|
72
|
+
send, skipped = False, 0
|
|
73
|
+
conn.execute(
|
|
74
|
+
"""
|
|
75
|
+
UPDATE error_groups
|
|
76
|
+
SET last_seen = ?, count_since_last_sent = count_since_last_sent + 1,
|
|
77
|
+
total_count = total_count + 1
|
|
78
|
+
WHERE fingerprint = ?
|
|
79
|
+
""",
|
|
80
|
+
(now, fingerprint),
|
|
81
|
+
)
|
|
82
|
+
conn.execute("DELETE FROM error_groups WHERE last_seen < ?", (now - _RETENTION_SECONDS,))
|
|
83
|
+
return send, skipped
|
|
84
|
+
finally:
|
|
85
|
+
conn.close()
|
|
86
|
+
except (sqlite3.Error, OSError) as error:
|
|
87
|
+
# ponytail: dedup/rate-limit state must never block an alert -- fail
|
|
88
|
+
# open (send, as if this were the first occurrence) on any DB error.
|
|
89
|
+
print(f"devalerts: dedup/rate-limit state error, sending anyway: {error}", file=sys.stderr)
|
|
90
|
+
return True, 0
|