devalerts 0.1.0__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.
@@ -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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/devalerts)](https://pypi.org/project/devalerts/)
31
+ [![Python versions](https://img.shields.io/pypi/pyversions/devalerts)](https://pypi.org/project/devalerts/)
32
+ [![License: MIT](https://img.shields.io/pypi/l/devalerts)](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
+ [![PyPI](https://img.shields.io/pypi/v/devalerts)](https://pypi.org/project/devalerts/)
4
+ [![Python versions](https://img.shields.io/pypi/pyversions/devalerts)](https://pypi.org/project/devalerts/)
5
+ [![License: MIT](https://img.shields.io/pypi/l/devalerts)](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