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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.10.2
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ devalerts = devalerts.cli:main
3
+