devalerts 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devalerts/__init__.py +270 -0
- devalerts/cli.py +67 -0
- devalerts/py.typed +0 -0
- devalerts-0.1.0.dist-info/METADATA +105 -0
- devalerts-0.1.0.dist-info/RECORD +7 -0
- devalerts-0.1.0.dist-info/WHEEL +4 -0
- devalerts-0.1.0.dist-info/entry_points.txt +3 -0
devalerts/__init__.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Throwaway prototype: send unhandled Python exceptions straight to a Telegram chat."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sqlite3
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import traceback
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
__all__: list[str] = ["init", "report", "capture", "ASGIMiddleware"]
|
|
19
|
+
|
|
20
|
+
_MAX_MESSAGE_LENGTH = 4096
|
|
21
|
+
_DB_PATH = Path.home() / ".devalerts" / "state.db"
|
|
22
|
+
_RETENTION_SECONDS = 7 * 24 * 3600
|
|
23
|
+
_DEFAULT_RATE_LIMIT_SECONDS = 300
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _format_alert(exc_type, exc_value, tb, skipped: int = 0) -> str:
|
|
27
|
+
header = f"\U0001F534 {exc_type.__name__}: {exc_value}"
|
|
28
|
+
if skipped:
|
|
29
|
+
header += f"\n⚠️ Повторилась ещё {skipped} раз(а) с последнего алерта"
|
|
30
|
+
body = "".join(traceback.format_exception(exc_type, exc_value, tb))
|
|
31
|
+
message = f"{header}\n\n{body}"
|
|
32
|
+
if len(message) <= _MAX_MESSAGE_LENGTH:
|
|
33
|
+
return message
|
|
34
|
+
marker = "\n\n...(truncated)...\n"
|
|
35
|
+
# ponytail: header itself can exceed the limit if exc_value's str() is huge
|
|
36
|
+
# (e.g. a validation error echoing a large payload) — keep can go negative,
|
|
37
|
+
# so clamp it and hard-truncate the final result as a backstop guarantee.
|
|
38
|
+
keep = max(_MAX_MESSAGE_LENGTH - len(header) - len(marker), 0)
|
|
39
|
+
truncated = f"{header}{marker}{body[-keep:]}" if keep else header
|
|
40
|
+
return truncated[:_MAX_MESSAGE_LENGTH]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ponytail: fixed pattern list, not exhaustive — catches common
|
|
44
|
+
# token/key shapes only. Upgrade to entropy-based detection if
|
|
45
|
+
# real users report leaked secrets slipping through.
|
|
46
|
+
_REDACT_PATTERNS = [
|
|
47
|
+
(re.compile(r"AKIA[0-9A-Z]{16}"), "[REDACTED]"),
|
|
48
|
+
(re.compile(r"Bearer\s+[A-Za-z0-9\-_.]+", re.IGNORECASE), "Bearer [REDACTED]"),
|
|
49
|
+
(
|
|
50
|
+
re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[=:]\s*\S+"),
|
|
51
|
+
r"\1=[REDACTED]",
|
|
52
|
+
),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _redact(text: str) -> str:
|
|
57
|
+
for pattern, replacement in _REDACT_PATTERNS:
|
|
58
|
+
text = pattern.sub(replacement, text)
|
|
59
|
+
return text
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_TIMEOUT_SECONDS = 5
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _send_telegram_message(bot_token: str, chat_id, text: str) -> None:
|
|
66
|
+
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
67
|
+
payload = json.dumps({"chat_id": chat_id, "text": text}).encode("utf-8")
|
|
68
|
+
request = urllib.request.Request(
|
|
69
|
+
url,
|
|
70
|
+
data=payload,
|
|
71
|
+
headers={"Content-Type": "application/json"},
|
|
72
|
+
method="POST",
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
urllib.request.urlopen(request, timeout=_TIMEOUT_SECONDS)
|
|
76
|
+
except (urllib.error.URLError, OSError, ValueError) as error:
|
|
77
|
+
print(f"devalerts: failed to send Telegram alert: {error}", file=sys.stderr)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_state = {
|
|
81
|
+
"bot_token": None,
|
|
82
|
+
"chat_id": None,
|
|
83
|
+
"redact": True,
|
|
84
|
+
"rate_limit_seconds": _DEFAULT_RATE_LIMIT_SECONDS,
|
|
85
|
+
"prev_excepthook": None,
|
|
86
|
+
"prev_threading_excepthook": None,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _fingerprint(exc_type, tb) -> tuple[str, str]:
|
|
91
|
+
frames = traceback.extract_tb(tb)
|
|
92
|
+
location = f"{frames[-1].filename}:{frames[-1].lineno}" if frames else "unknown"
|
|
93
|
+
raw = f"{exc_type.__name__}:{location}"
|
|
94
|
+
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16], location
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _get_connection() -> sqlite3.Connection:
|
|
98
|
+
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
conn = sqlite3.connect(_DB_PATH, timeout=5)
|
|
100
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
101
|
+
conn.execute(
|
|
102
|
+
"""
|
|
103
|
+
CREATE TABLE IF NOT EXISTS error_groups (
|
|
104
|
+
fingerprint TEXT PRIMARY KEY,
|
|
105
|
+
exc_type TEXT NOT NULL,
|
|
106
|
+
location TEXT NOT NULL,
|
|
107
|
+
first_seen REAL NOT NULL,
|
|
108
|
+
last_seen REAL NOT NULL,
|
|
109
|
+
last_sent REAL,
|
|
110
|
+
count_since_last_sent INTEGER NOT NULL DEFAULT 0,
|
|
111
|
+
total_count INTEGER NOT NULL DEFAULT 0
|
|
112
|
+
)
|
|
113
|
+
"""
|
|
114
|
+
)
|
|
115
|
+
return conn
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _should_send(fingerprint: str, exc_type_name: str, location: str, rate_limit_seconds: int) -> tuple[bool, int]:
|
|
119
|
+
now = time.time()
|
|
120
|
+
try:
|
|
121
|
+
conn = _get_connection()
|
|
122
|
+
try:
|
|
123
|
+
with conn:
|
|
124
|
+
row = conn.execute(
|
|
125
|
+
"SELECT last_sent, count_since_last_sent FROM error_groups WHERE fingerprint = ?",
|
|
126
|
+
(fingerprint,),
|
|
127
|
+
).fetchone()
|
|
128
|
+
if row is None or row[0] is None or now - row[0] >= rate_limit_seconds:
|
|
129
|
+
send, skipped = True, (row[1] if row else 0)
|
|
130
|
+
conn.execute(
|
|
131
|
+
"""
|
|
132
|
+
INSERT INTO error_groups
|
|
133
|
+
(fingerprint, exc_type, location, first_seen, last_seen,
|
|
134
|
+
last_sent, count_since_last_sent, total_count)
|
|
135
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, 1)
|
|
136
|
+
ON CONFLICT(fingerprint) DO UPDATE SET
|
|
137
|
+
last_seen = excluded.last_seen,
|
|
138
|
+
last_sent = excluded.last_sent,
|
|
139
|
+
count_since_last_sent = 0,
|
|
140
|
+
total_count = total_count + 1
|
|
141
|
+
""",
|
|
142
|
+
(fingerprint, exc_type_name, location, now, now, now),
|
|
143
|
+
)
|
|
144
|
+
else:
|
|
145
|
+
send, skipped = False, 0
|
|
146
|
+
conn.execute(
|
|
147
|
+
"""
|
|
148
|
+
UPDATE error_groups
|
|
149
|
+
SET last_seen = ?, count_since_last_sent = count_since_last_sent + 1,
|
|
150
|
+
total_count = total_count + 1
|
|
151
|
+
WHERE fingerprint = ?
|
|
152
|
+
""",
|
|
153
|
+
(now, fingerprint),
|
|
154
|
+
)
|
|
155
|
+
conn.execute("DELETE FROM error_groups WHERE last_seen < ?", (now - _RETENTION_SECONDS,))
|
|
156
|
+
return send, skipped
|
|
157
|
+
finally:
|
|
158
|
+
conn.close()
|
|
159
|
+
except (sqlite3.Error, OSError) as error:
|
|
160
|
+
# ponytail: dedup/rate-limit state must never block an alert -- fail
|
|
161
|
+
# open (send, as if this were the first occurrence) on any DB error.
|
|
162
|
+
print(f"devalerts: dedup/rate-limit state error, sending anyway: {error}", file=sys.stderr)
|
|
163
|
+
return True, 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _send_exception(exc_type, exc_value, tb) -> None:
|
|
167
|
+
fingerprint, location = _fingerprint(exc_type, tb)
|
|
168
|
+
send, skipped = _should_send(fingerprint, exc_type.__name__, location, _state["rate_limit_seconds"])
|
|
169
|
+
if not send:
|
|
170
|
+
return
|
|
171
|
+
message = _format_alert(exc_type, exc_value, tb, skipped=skipped)
|
|
172
|
+
if _state["redact"]:
|
|
173
|
+
message = _redact(message)
|
|
174
|
+
_send_telegram_message(_state["bot_token"], _state["chat_id"], message)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _excepthook(exc_type, exc_value, tb) -> None:
|
|
178
|
+
if exc_type is not KeyboardInterrupt:
|
|
179
|
+
try:
|
|
180
|
+
_send_exception(exc_type, exc_value, tb)
|
|
181
|
+
except Exception as error: # noqa: BLE001 - crash handler must never raise
|
|
182
|
+
print(f"devalerts: internal error while sending alert: {error}", file=sys.stderr)
|
|
183
|
+
_state["prev_excepthook"](exc_type, exc_value, tb)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _threading_excepthook(args) -> None:
|
|
187
|
+
try:
|
|
188
|
+
_send_exception(args.exc_type, args.exc_value, args.exc_traceback)
|
|
189
|
+
except Exception as error: # noqa: BLE001
|
|
190
|
+
print(f"devalerts: internal error while sending alert: {error}", file=sys.stderr)
|
|
191
|
+
_state["prev_threading_excepthook"](args)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def init(
|
|
195
|
+
bot_token: str,
|
|
196
|
+
chat_id,
|
|
197
|
+
*,
|
|
198
|
+
redact: bool = True,
|
|
199
|
+
rate_limit_seconds: int = _DEFAULT_RATE_LIMIT_SECONDS,
|
|
200
|
+
) -> None:
|
|
201
|
+
"""Install a global exception hook that sends unhandled exceptions to Telegram."""
|
|
202
|
+
_state["bot_token"] = bot_token
|
|
203
|
+
_state["chat_id"] = chat_id
|
|
204
|
+
_state["redact"] = redact
|
|
205
|
+
_state["rate_limit_seconds"] = rate_limit_seconds
|
|
206
|
+
# ponytail: guard against calling init() twice capturing our own hook as
|
|
207
|
+
# "previous" -- that would make _excepthook chain to itself and recurse
|
|
208
|
+
# forever on the next crash, violating "must never raise".
|
|
209
|
+
if sys.excepthook is not _excepthook:
|
|
210
|
+
_state["prev_excepthook"] = sys.excepthook
|
|
211
|
+
if threading.excepthook is not _threading_excepthook:
|
|
212
|
+
_state["prev_threading_excepthook"] = threading.excepthook
|
|
213
|
+
sys.excepthook = _excepthook
|
|
214
|
+
threading.excepthook = _threading_excepthook
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def report(exc: BaseException | None = None) -> None:
|
|
218
|
+
"""Manually send a caught exception to Telegram."""
|
|
219
|
+
if exc is None:
|
|
220
|
+
exc_type, exc_value, tb = sys.exc_info()
|
|
221
|
+
if exc_type is None:
|
|
222
|
+
raise RuntimeError("report() requires an active exception or an exc argument")
|
|
223
|
+
else:
|
|
224
|
+
exc_type, exc_value, tb = type(exc), exc, exc.__traceback__
|
|
225
|
+
_send_exception(exc_type, exc_value, tb)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class capture(contextlib.ContextDecorator):
|
|
229
|
+
"""Context manager / decorator: report any exception raised inside the block or
|
|
230
|
+
function, then re-raise it. Use ``@capture()`` on a function instead of wrapping
|
|
231
|
+
its body in a manual ``try/except`` or ``with`` block."""
|
|
232
|
+
|
|
233
|
+
def __enter__(self) -> "capture":
|
|
234
|
+
return self
|
|
235
|
+
|
|
236
|
+
def __exit__(self, exc_type, exc_value, tb) -> bool:
|
|
237
|
+
if exc_type is not None:
|
|
238
|
+
_send_exception(exc_type, exc_value, tb)
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class ASGIMiddleware:
|
|
243
|
+
"""ASGI middleware for FastAPI/Starlette (or any ASGI app): report any exception
|
|
244
|
+
that escapes a request, then re-raise it so the framework's own error handling
|
|
245
|
+
still runs unchanged.
|
|
246
|
+
|
|
247
|
+
Usage::
|
|
248
|
+
|
|
249
|
+
app.add_middleware(devalerts.ASGIMiddleware)
|
|
250
|
+
|
|
251
|
+
Only exceptions that actually reach here (unhandled server errors) get reported —
|
|
252
|
+
routing 404s and raised ``HTTPException``s are already turned into responses by
|
|
253
|
+
the framework before this middleware sees them, same as Sentry's ASGI integration.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
def __init__(self, app) -> None:
|
|
257
|
+
self.app = app
|
|
258
|
+
|
|
259
|
+
async def __call__(self, scope, receive, send) -> None:
|
|
260
|
+
try:
|
|
261
|
+
await self.app(scope, receive, send)
|
|
262
|
+
except Exception:
|
|
263
|
+
exc_type, exc_value, tb = sys.exc_info()
|
|
264
|
+
# ponytail: fire-and-forget in a thread -- _send_exception is a
|
|
265
|
+
# blocking network call; awaiting it here would stall the event
|
|
266
|
+
# loop (and the error response) for up to _TIMEOUT_SECONDS.
|
|
267
|
+
threading.Thread(
|
|
268
|
+
target=_send_exception, args=(exc_type, exc_value, tb), daemon=True
|
|
269
|
+
).start()
|
|
270
|
+
raise
|
devalerts/cli.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""CLI: `devalerts dashboard` reports grouped/rate-limited errors from the local state DB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sqlite3
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from . import _DB_PATH, _DEFAULT_RATE_LIMIT_SECONDS
|
|
12
|
+
|
|
13
|
+
_LOCATION_WIDTH = 30
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _truncate(text: str, width: int) -> str:
|
|
17
|
+
"""Keep the tail (file:line matters more than leading directories)."""
|
|
18
|
+
return text if len(text) <= width else "…" + text[-(width - 1):]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _dashboard() -> int:
|
|
22
|
+
if not _DB_PATH.exists():
|
|
23
|
+
print("No errors recorded yet.")
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
conn = sqlite3.connect(_DB_PATH)
|
|
27
|
+
try:
|
|
28
|
+
rows = conn.execute(
|
|
29
|
+
"SELECT fingerprint, exc_type, location, last_seen, last_sent, total_count "
|
|
30
|
+
"FROM error_groups ORDER BY last_seen DESC"
|
|
31
|
+
).fetchall()
|
|
32
|
+
finally:
|
|
33
|
+
conn.close()
|
|
34
|
+
|
|
35
|
+
if not rows:
|
|
36
|
+
print("No errors recorded yet.")
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
now = time.time()
|
|
40
|
+
print(f"{'FINGERPRINT':<18}{'TYPE':<16}{'LOCATION':<30}{'LAST SEEN':<21}{'TOTAL':>7} IN WINDOW")
|
|
41
|
+
for fingerprint, exc_type, location, last_seen, last_sent, total_count in rows:
|
|
42
|
+
last_seen_str = datetime.fromtimestamp(last_seen).strftime("%Y-%m-%d %H:%M:%S")
|
|
43
|
+
# ponytail: the dashboard runs in a separate process from init(), so it
|
|
44
|
+
# doesn't know the app's actual rate_limit_seconds -- uses the library
|
|
45
|
+
# default. Upgrade to persisting the configured value if apps commonly
|
|
46
|
+
# override it.
|
|
47
|
+
in_window = last_sent is not None and now - last_sent < _DEFAULT_RATE_LIMIT_SECONDS
|
|
48
|
+
print(
|
|
49
|
+
f"{fingerprint:<18}{exc_type:<16}{location:<30}{last_seen_str:<21}"
|
|
50
|
+
f"{total_count:>7} {'yes' if in_window else 'no'}"
|
|
51
|
+
)
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv: list[str] | None = None) -> int:
|
|
56
|
+
parser = argparse.ArgumentParser(prog="devalerts")
|
|
57
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
58
|
+
subparsers.add_parser("dashboard", help="Show grouped/rate-limited errors")
|
|
59
|
+
args = parser.parse_args(argv)
|
|
60
|
+
|
|
61
|
+
if args.command == "dashboard":
|
|
62
|
+
return _dashboard()
|
|
63
|
+
return 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
sys.exit(main())
|
devalerts/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: devalerts
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: sslinNn
|
|
6
|
+
Author-email: sslinNn <morison1991@mail.ru>
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# devalerts (throwaway prototype)
|
|
11
|
+
|
|
12
|
+
[Русская версия](README.ru.md)
|
|
13
|
+
|
|
14
|
+
Send unhandled Python exceptions straight to a Telegram chat. No backend,
|
|
15
|
+
no account, no database — just your own bot token.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
uv add git+https://github.com/<you>/<repo>.git
|
|
20
|
+
|
|
21
|
+
(or `pip install git+https://github.com/<you>/<repo>.git` if you're not using uv)
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
1. Create a bot with [@BotFather](https://t.me/BotFather) and get its token.
|
|
26
|
+
2. Message your bot once (or add it to a group) so it's allowed to message you back.
|
|
27
|
+
3. Get your chat id — message [@userinfobot](https://t.me/userinfobot), or call
|
|
28
|
+
`https://api.telegram.org/bot<TOKEN>/getUpdates` after step 2 and read `message.chat.id`.
|
|
29
|
+
4. In your app, as early as possible:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import devalerts
|
|
33
|
+
|
|
34
|
+
devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
That's it — any unhandled exception (including ones raised in threads) now
|
|
38
|
+
also lands in your Telegram chat.
|
|
39
|
+
|
|
40
|
+
## Grouping, rate limiting, and the dashboard
|
|
41
|
+
|
|
42
|
+
Exceptions are grouped by fingerprint (exception type + file + line where it
|
|
43
|
+
was raised) in a local SQLite file (`~/.devalerts/state.db`). Each group
|
|
44
|
+
sends at most one Telegram message per `rate_limit_seconds` (default 300);
|
|
45
|
+
repeats inside that window are counted but not sent, and the next message
|
|
46
|
+
for that group says how many were skipped. Old groups (untouched for 7 days)
|
|
47
|
+
are pruned automatically. Configure the window via `init()`:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
devalerts.init(bot_token="...", chat_id=123456789, rate_limit_seconds=60)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
See what's grouped and what's currently rate-limited:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
uv run devalerts dashboard
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Manually reporting a caught exception
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
try:
|
|
63
|
+
risky_call()
|
|
64
|
+
except Exception:
|
|
65
|
+
devalerts.report() # sends the currently-handled exception
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
or:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
with devalerts.capture():
|
|
72
|
+
risky_call() # reports on exception, then re-raises
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`capture` also works as a decorator, so you don't need to touch a function's
|
|
76
|
+
body at all:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
@devalerts.capture()
|
|
80
|
+
def risky_call():
|
|
81
|
+
...
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## FastAPI / Starlette / any ASGI app
|
|
85
|
+
|
|
86
|
+
`init()`'s excepthook won't see request errors — the framework already catches
|
|
87
|
+
them internally to return a 500 response, so nothing "unhandled" ever reaches
|
|
88
|
+
the process. Use the ASGI middleware instead:
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
app.add_middleware(devalerts.ASGIMiddleware)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Only exceptions that actually escape as server errors get reported — routing
|
|
95
|
+
404s and raised `HTTPException`s are already turned into responses by the
|
|
96
|
+
framework before the middleware sees them.
|
|
97
|
+
|
|
98
|
+
## What this does NOT do (by design — it's a throwaway prototype)
|
|
99
|
+
|
|
100
|
+
- Grouping/rate limiting is local and in-process only (SQLite file, no
|
|
101
|
+
server) — the dashboard is a CLI table, not a web UI.
|
|
102
|
+
- No backend, no accounts — each user runs their own bot.
|
|
103
|
+
- Basic secret redaction only (a few common token patterns) — do not rely
|
|
104
|
+
on this for sensitive production data.
|
|
105
|
+
- No automated test suite — verified manually during implementation only.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
devalerts/__init__.py,sha256=RYebKii-DW-We-CcgmyV9jwL-vfFO4gxqyhDNwmmf4w,10282
|
|
2
|
+
devalerts/cli.py,sha256=h--jYC86_o7LPLMGOhgsL0eFUT3wCRPcuNIgtbyl_I0,2200
|
|
3
|
+
devalerts/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
devalerts-0.1.0.dist-info/WHEEL,sha256=QnGI6l9Psotmz6XrseXTYT5jnZRJeTk4SwJP4aLtfdI,80
|
|
5
|
+
devalerts-0.1.0.dist-info/entry_points.txt,sha256=G45eB-0ASM1EKddRxB6weifD1MomdTTkqNQKjuGztQc,50
|
|
6
|
+
devalerts-0.1.0.dist-info/METADATA,sha256=JE0vsU34T208TJsAqYvZh-XZBi8g4QsN85MvpWkaeN4,3198
|
|
7
|
+
devalerts-0.1.0.dist-info/RECORD,,
|