devalerts 0.1.3__tar.gz → 0.1.5__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,226 @@
1
+ Metadata-Version: 2.4
2
+ Name: devalerts
3
+ Version: 0.1.5
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
+ [![Downloads](https://img.shields.io/pypi/dm/devalerts)](https://pypi.org/project/devalerts/)
32
+ [![Python versions](https://img.shields.io/pypi/pyversions/devalerts)](https://pypi.org/project/devalerts/)
33
+ [![License: MIT](https://img.shields.io/pypi/l/devalerts)](LICENSE)
34
+ [![Tests](https://img.shields.io/github/actions/workflow/status/sslinNn/devalerts/test.yml?branch=main&label=tests)](https://github.com/sslinNn/devalerts/actions/workflows/test.yml)
35
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
36
+ [![mypy](https://img.shields.io/badge/mypy-checked-blue)](https://mypy-lang.org/)
37
+
38
+ [Русская версия](README.ru.md)
39
+
40
+ Send unhandled Python exceptions straight to a Telegram chat — the moment
41
+ they happen, on your phone. No backend, no account, no database — just your
42
+ own bot token.
43
+
44
+ ```python
45
+ import devalerts
46
+
47
+ devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
48
+ ```
49
+
50
+ That's the whole setup. Two minutes with [@BotFather](https://t.me/BotFather)
51
+ and every unhandled crash — including ones raised in threads — lands in your
52
+ chat instead of a log file nobody's watching.
53
+
54
+ ## Why devalerts
55
+
56
+ - **Zero infrastructure.** No SaaS signup, no ingestion server, no API key
57
+ to manage beyond your own Telegram bot token. State lives in a local
58
+ SQLite file you already own.
59
+ - **One line to install, one line to wire up.** `init()` installs the hook
60
+ and gets out of the way.
61
+ - **Not spam.** Errors are grouped by fingerprint and rate-limited per
62
+ group, so a crash loop sends one message, not a thousand.
63
+ - **Framework-aware.** Ships an ASGI middleware for FastAPI/Starlette apps,
64
+ where the default excepthook would never even see a request error.
65
+ - **Small and typed.** No dependencies, ships `py.typed`, ~450 lines total —
66
+ short enough to read in one sitting before you trust it with your errors.
67
+
68
+ ## Install
69
+
70
+ uv add devalerts
71
+
72
+ (or `pip install devalerts` if you're not using uv)
73
+
74
+ ## Usage
75
+
76
+ 1. Create a bot with [@BotFather](https://t.me/BotFather) and get its token.
77
+ 2. Message your bot once (or add it to a group) so it's allowed to message you back.
78
+ 3. Get your chat id — message [@userinfobot](https://t.me/userinfobot), or call
79
+ `https://api.telegram.org/bot<TOKEN>/getUpdates` after step 2 and read `message.chat.id`.
80
+ 4. In your app, as early as possible:
81
+
82
+ ```python
83
+ import devalerts
84
+
85
+ devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
86
+ ```
87
+
88
+ That's it — any unhandled exception (including ones raised in threads) now
89
+ also lands in your Telegram chat.
90
+
91
+ ## Grouping, rate limiting, and the dashboard
92
+
93
+ Exceptions are grouped by fingerprint (exception type + file + line where it
94
+ was raised) in a local SQLite file (`~/.devalerts/state.db`). Each group
95
+ sends at most one Telegram message per `rate_limit_seconds` (default 300);
96
+ repeats inside that window are counted but not sent, and the next message
97
+ for that group says how many were skipped. Old groups (untouched for 7 days)
98
+ are pruned automatically. Configure the window via `init()`:
99
+
100
+ ```python
101
+ devalerts.init(bot_token="...", chat_id=123456789, rate_limit_seconds=60)
102
+ ```
103
+
104
+ See what's grouped and what's currently rate-limited:
105
+
106
+ ```
107
+ uv run devalerts dashboard
108
+ ```
109
+
110
+ ![devalerts dashboard output](https://raw.githubusercontent.com/sslinNn/devalerts/main/docs/dashboard.svg)
111
+
112
+ ## Manually reporting a caught exception
113
+
114
+ ```python
115
+ try:
116
+ risky_call()
117
+ except Exception:
118
+ devalerts.report() # sends the currently-handled exception
119
+ ```
120
+
121
+ or:
122
+
123
+ ```python
124
+ with devalerts.capture():
125
+ risky_call() # reports on exception, then re-raises
126
+ ```
127
+
128
+ `capture` also works as a decorator, so you don't need to touch a function's
129
+ body at all:
130
+
131
+ ```python
132
+ @devalerts.capture()
133
+ def risky_call():
134
+ ...
135
+ ```
136
+
137
+ ## FastAPI / Starlette / any ASGI app
138
+
139
+ `init()`'s excepthook won't see request errors — the framework already catches
140
+ them internally to return a 500 response, so nothing "unhandled" ever reaches
141
+ the process. Use the ASGI middleware instead:
142
+
143
+ ```python
144
+ app.add_middleware(devalerts.ASGIMiddleware)
145
+ ```
146
+
147
+ Only exceptions that actually escape as server errors get reported — routing
148
+ 404s and raised `HTTPException`s are already turned into responses by the
149
+ framework before the middleware sees them.
150
+
151
+ ## Why not Sentry?
152
+
153
+ If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
154
+ replacement. It's for the side project, internal tool, or small service that
155
+ doesn't have (and doesn't want) that infrastructure: no account to create, no
156
+ SDK to configure, no server to trust — just a bot token you already control.
157
+
158
+ | | devalerts | Sentry-style tracker |
159
+ |--------------------------|---------------------|-----------------------|
160
+ | Setup | one bot token | account + project + SDK config |
161
+ | Backend | none — Telegram only | hosted or self-hosted service |
162
+ | Where alerts land | your Telegram chat | a web dashboard |
163
+ | Grouping / rate limiting | yes, local SQLite | yes, server-side |
164
+ | Search, trends, releases | no | yes |
165
+
166
+ ## FAQ
167
+
168
+ **Works with FastAPI / Starlette?**
169
+ Yes — use [`ASGIMiddleware`](#fastapi--starlette--any-asgi-app), since the
170
+ default excepthook never sees request errors.
171
+
172
+ **Works with Docker?**
173
+ Yes, nothing container-specific. Just make sure `~/.devalerts/` (the dedup
174
+ state file) is either writable inside the container or a mounted volume if
175
+ you want dedup state to survive restarts — it self-recreates otherwise.
176
+
177
+ **Works with threads?**
178
+ Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
179
+
180
+ **Works with Celery / background workers?**
181
+ Yes, `init()` in the worker process the same way as in a web process. For
182
+ tasks you catch yourself, use `report()` or `@devalerts.capture()`.
183
+
184
+ **Works on Windows / Linux / macOS?**
185
+ Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
186
+ paths.
187
+
188
+ ## Privacy & Security
189
+
190
+ - The only network call devalerts makes is to `api.telegram.org` — no
191
+ telemetry, no analytics, nothing else phones home.
192
+ - No third-party server and no devalerts-run backend — messages go straight
193
+ from your process to your own Telegram bot.
194
+ - No accounts, no signup, no API key beyond the bot token you create and
195
+ control yourself.
196
+ - Basic secret redaction only (a few common token/key patterns) — do not
197
+ rely on this for sensitive production data; scrub what you can before it
198
+ ever reaches an exception message.
199
+
200
+ ## What this does NOT do (by design)
201
+
202
+ - Grouping/rate limiting is local and in-process only (SQLite file, no
203
+ server) — the dashboard is a CLI table, not a web UI.
204
+ - No backend, no accounts — each user runs their own bot.
205
+
206
+ ## Roadmap
207
+
208
+ - Web dashboard (hosted, optional — the local CLI dashboard stays either way)
209
+ - Slack delivery
210
+ - Discord delivery
211
+ - Email delivery
212
+
213
+ ## Development
214
+
215
+ uv sync --group dev
216
+ uv run pre-commit install
217
+
218
+ `pre-commit` runs `ruff check`, `ruff format`, and `mypy` before each commit.
219
+ Run the full check manually with:
220
+
221
+ uv run pytest
222
+ uv run pre-commit run --all-files
223
+
224
+ ## License
225
+
226
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,199 @@
1
+ # devalerts
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/devalerts)](https://pypi.org/project/devalerts/)
4
+ [![Downloads](https://img.shields.io/pypi/dm/devalerts)](https://pypi.org/project/devalerts/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/devalerts)](https://pypi.org/project/devalerts/)
6
+ [![License: MIT](https://img.shields.io/pypi/l/devalerts)](LICENSE)
7
+ [![Tests](https://img.shields.io/github/actions/workflow/status/sslinNn/devalerts/test.yml?branch=main&label=tests)](https://github.com/sslinNn/devalerts/actions/workflows/test.yml)
8
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
9
+ [![mypy](https://img.shields.io/badge/mypy-checked-blue)](https://mypy-lang.org/)
10
+
11
+ [Русская версия](README.ru.md)
12
+
13
+ Send unhandled Python exceptions straight to a Telegram chat — the moment
14
+ they happen, on your phone. No backend, no account, no database — just your
15
+ own bot token.
16
+
17
+ ```python
18
+ import devalerts
19
+
20
+ devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
21
+ ```
22
+
23
+ That's the whole setup. Two minutes with [@BotFather](https://t.me/BotFather)
24
+ and every unhandled crash — including ones raised in threads — lands in your
25
+ chat instead of a log file nobody's watching.
26
+
27
+ ## Why devalerts
28
+
29
+ - **Zero infrastructure.** No SaaS signup, no ingestion server, no API key
30
+ to manage beyond your own Telegram bot token. State lives in a local
31
+ SQLite file you already own.
32
+ - **One line to install, one line to wire up.** `init()` installs the hook
33
+ and gets out of the way.
34
+ - **Not spam.** Errors are grouped by fingerprint and rate-limited per
35
+ group, so a crash loop sends one message, not a thousand.
36
+ - **Framework-aware.** Ships an ASGI middleware for FastAPI/Starlette apps,
37
+ where the default excepthook would never even see a request error.
38
+ - **Small and typed.** No dependencies, ships `py.typed`, ~450 lines total —
39
+ short enough to read in one sitting before you trust it with your errors.
40
+
41
+ ## Install
42
+
43
+ uv add devalerts
44
+
45
+ (or `pip install devalerts` if you're not using uv)
46
+
47
+ ## Usage
48
+
49
+ 1. Create a bot with [@BotFather](https://t.me/BotFather) and get its token.
50
+ 2. Message your bot once (or add it to a group) so it's allowed to message you back.
51
+ 3. Get your chat id — message [@userinfobot](https://t.me/userinfobot), or call
52
+ `https://api.telegram.org/bot<TOKEN>/getUpdates` after step 2 and read `message.chat.id`.
53
+ 4. In your app, as early as possible:
54
+
55
+ ```python
56
+ import devalerts
57
+
58
+ devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
59
+ ```
60
+
61
+ That's it — any unhandled exception (including ones raised in threads) now
62
+ also lands in your Telegram chat.
63
+
64
+ ## Grouping, rate limiting, and the dashboard
65
+
66
+ Exceptions are grouped by fingerprint (exception type + file + line where it
67
+ was raised) in a local SQLite file (`~/.devalerts/state.db`). Each group
68
+ sends at most one Telegram message per `rate_limit_seconds` (default 300);
69
+ repeats inside that window are counted but not sent, and the next message
70
+ for that group says how many were skipped. Old groups (untouched for 7 days)
71
+ are pruned automatically. Configure the window via `init()`:
72
+
73
+ ```python
74
+ devalerts.init(bot_token="...", chat_id=123456789, rate_limit_seconds=60)
75
+ ```
76
+
77
+ See what's grouped and what's currently rate-limited:
78
+
79
+ ```
80
+ uv run devalerts dashboard
81
+ ```
82
+
83
+ ![devalerts dashboard output](https://raw.githubusercontent.com/sslinNn/devalerts/main/docs/dashboard.svg)
84
+
85
+ ## Manually reporting a caught exception
86
+
87
+ ```python
88
+ try:
89
+ risky_call()
90
+ except Exception:
91
+ devalerts.report() # sends the currently-handled exception
92
+ ```
93
+
94
+ or:
95
+
96
+ ```python
97
+ with devalerts.capture():
98
+ risky_call() # reports on exception, then re-raises
99
+ ```
100
+
101
+ `capture` also works as a decorator, so you don't need to touch a function's
102
+ body at all:
103
+
104
+ ```python
105
+ @devalerts.capture()
106
+ def risky_call():
107
+ ...
108
+ ```
109
+
110
+ ## FastAPI / Starlette / any ASGI app
111
+
112
+ `init()`'s excepthook won't see request errors — the framework already catches
113
+ them internally to return a 500 response, so nothing "unhandled" ever reaches
114
+ the process. Use the ASGI middleware instead:
115
+
116
+ ```python
117
+ app.add_middleware(devalerts.ASGIMiddleware)
118
+ ```
119
+
120
+ Only exceptions that actually escape as server errors get reported — routing
121
+ 404s and raised `HTTPException`s are already turned into responses by the
122
+ framework before the middleware sees them.
123
+
124
+ ## Why not Sentry?
125
+
126
+ If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
127
+ replacement. It's for the side project, internal tool, or small service that
128
+ doesn't have (and doesn't want) that infrastructure: no account to create, no
129
+ SDK to configure, no server to trust — just a bot token you already control.
130
+
131
+ | | devalerts | Sentry-style tracker |
132
+ |--------------------------|---------------------|-----------------------|
133
+ | Setup | one bot token | account + project + SDK config |
134
+ | Backend | none — Telegram only | hosted or self-hosted service |
135
+ | Where alerts land | your Telegram chat | a web dashboard |
136
+ | Grouping / rate limiting | yes, local SQLite | yes, server-side |
137
+ | Search, trends, releases | no | yes |
138
+
139
+ ## FAQ
140
+
141
+ **Works with FastAPI / Starlette?**
142
+ Yes — use [`ASGIMiddleware`](#fastapi--starlette--any-asgi-app), since the
143
+ default excepthook never sees request errors.
144
+
145
+ **Works with Docker?**
146
+ Yes, nothing container-specific. Just make sure `~/.devalerts/` (the dedup
147
+ state file) is either writable inside the container or a mounted volume if
148
+ you want dedup state to survive restarts — it self-recreates otherwise.
149
+
150
+ **Works with threads?**
151
+ Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
152
+
153
+ **Works with Celery / background workers?**
154
+ Yes, `init()` in the worker process the same way as in a web process. For
155
+ tasks you catch yourself, use `report()` or `@devalerts.capture()`.
156
+
157
+ **Works on Windows / Linux / macOS?**
158
+ Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
159
+ paths.
160
+
161
+ ## Privacy & Security
162
+
163
+ - The only network call devalerts makes is to `api.telegram.org` — no
164
+ telemetry, no analytics, nothing else phones home.
165
+ - No third-party server and no devalerts-run backend — messages go straight
166
+ from your process to your own Telegram bot.
167
+ - No accounts, no signup, no API key beyond the bot token you create and
168
+ control yourself.
169
+ - Basic secret redaction only (a few common token/key patterns) — do not
170
+ rely on this for sensitive production data; scrub what you can before it
171
+ ever reaches an exception message.
172
+
173
+ ## What this does NOT do (by design)
174
+
175
+ - Grouping/rate limiting is local and in-process only (SQLite file, no
176
+ server) — the dashboard is a CLI table, not a web UI.
177
+ - No backend, no accounts — each user runs their own bot.
178
+
179
+ ## Roadmap
180
+
181
+ - Web dashboard (hosted, optional — the local CLI dashboard stays either way)
182
+ - Slack delivery
183
+ - Discord delivery
184
+ - Email delivery
185
+
186
+ ## Development
187
+
188
+ uv sync --group dev
189
+ uv run pre-commit install
190
+
191
+ `pre-commit` runs `ruff check`, `ruff format`, and `mypy` before each commit.
192
+ Run the full check manually with:
193
+
194
+ uv run pytest
195
+ uv run pre-commit run --all-files
196
+
197
+ ## License
198
+
199
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "devalerts"
3
+ version = "0.1.5"
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.12.0"]
47
+ build-backend = "uv_build"
48
+
49
+ [dependency-groups]
50
+ dev = [
51
+ "mypy>=1.19.1",
52
+ "pre-commit>=4.3.0",
53
+ "pytest>=8.4.2",
54
+ "ruff>=0.15.22",
55
+ ]
56
+
57
+ [tool.ruff]
58
+ target-version = "py39"
59
+
60
+ [tool.ruff.lint]
61
+ extend-select = ["BLE"]
@@ -0,0 +1,162 @@
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
+ from types import TracebackType
9
+ from typing import Callable, Literal, Optional, TypedDict
10
+
11
+ from ._alert import _format_alert, _redact
12
+ from ._store import _DEFAULT_RATE_LIMIT_SECONDS, _fingerprint, _should_send
13
+ from ._telegram import _send_telegram_message
14
+
15
+ __all__: list[str] = ["init", "report", "capture", "ASGIMiddleware"]
16
+
17
+ _ExceptHook = Callable[
18
+ [type[BaseException], BaseException, Optional[TracebackType]], object
19
+ ]
20
+ _ThreadingExceptHook = Callable[[threading.ExceptHookArgs], object]
21
+
22
+
23
+ class _State(TypedDict):
24
+ bot_token: str | None
25
+ chat_id: int | str | None
26
+ redact: bool
27
+ rate_limit_seconds: int
28
+ # Seeded with the real default hooks below (never None) -- _excepthook/
29
+ # _threading_excepthook can only ever fire after init() has replaced
30
+ # sys.excepthook/threading.excepthook, by which point these are always
31
+ # set, so keeping them non-Optional avoids an unreachable None check.
32
+ prev_excepthook: _ExceptHook
33
+ prev_threading_excepthook: _ThreadingExceptHook
34
+
35
+
36
+ _state: _State = {
37
+ "bot_token": None,
38
+ "chat_id": None,
39
+ "redact": True,
40
+ "rate_limit_seconds": _DEFAULT_RATE_LIMIT_SECONDS,
41
+ "prev_excepthook": sys.excepthook,
42
+ "prev_threading_excepthook": threading.excepthook,
43
+ }
44
+
45
+
46
+ def _send_exception(exc_type, exc_value, tb) -> None:
47
+ if _state["bot_token"] is None or _state["chat_id"] is None:
48
+ print("devalerts: init() was not called, dropping alert", file=sys.stderr)
49
+ return
50
+ fingerprint, location = _fingerprint(exc_type, tb)
51
+ send, skipped = _should_send(
52
+ fingerprint, exc_type.__name__, location, _state["rate_limit_seconds"]
53
+ )
54
+ if not send:
55
+ return
56
+ message = _format_alert(exc_type, exc_value, tb, skipped=skipped)
57
+ if _state["redact"]:
58
+ message = _redact(message)
59
+ _send_telegram_message(_state["bot_token"], _state["chat_id"], message)
60
+
61
+
62
+ def _excepthook(exc_type, exc_value, tb) -> None:
63
+ if exc_type is not KeyboardInterrupt:
64
+ try:
65
+ _send_exception(exc_type, exc_value, tb)
66
+ except Exception as error: # noqa: BLE001 - crash handler must never raise
67
+ print(
68
+ f"devalerts: internal error while sending alert: {error}",
69
+ file=sys.stderr,
70
+ )
71
+ _state["prev_excepthook"](exc_type, exc_value, tb)
72
+
73
+
74
+ def _threading_excepthook(args) -> None:
75
+ try:
76
+ _send_exception(args.exc_type, args.exc_value, args.exc_traceback)
77
+ except Exception as error: # noqa: BLE001
78
+ print(
79
+ f"devalerts: internal error while sending alert: {error}", file=sys.stderr
80
+ )
81
+ _state["prev_threading_excepthook"](args)
82
+
83
+
84
+ def init(
85
+ bot_token: str,
86
+ chat_id: int | str,
87
+ *,
88
+ redact: bool = True,
89
+ rate_limit_seconds: int = _DEFAULT_RATE_LIMIT_SECONDS,
90
+ ) -> None:
91
+ """Install a global exception hook that sends unhandled exceptions to Telegram."""
92
+ _state["bot_token"] = bot_token
93
+ _state["chat_id"] = chat_id
94
+ _state["redact"] = redact
95
+ _state["rate_limit_seconds"] = rate_limit_seconds
96
+ # ponytail: guard against calling init() twice capturing our own hook as
97
+ # "previous" -- that would make _excepthook chain to itself and recurse
98
+ # forever on the next crash, violating "must never raise".
99
+ if sys.excepthook is not _excepthook:
100
+ _state["prev_excepthook"] = sys.excepthook
101
+ if threading.excepthook is not _threading_excepthook:
102
+ _state["prev_threading_excepthook"] = threading.excepthook
103
+ sys.excepthook = _excepthook
104
+ threading.excepthook = _threading_excepthook
105
+
106
+
107
+ def report(exc: BaseException | None = None) -> None:
108
+ """Manually send a caught exception to Telegram."""
109
+ if exc is None:
110
+ exc_type, exc_value, tb = sys.exc_info()
111
+ if exc_type is None:
112
+ raise RuntimeError(
113
+ "report() requires an active exception or an exc argument"
114
+ )
115
+ else:
116
+ exc_type, exc_value, tb = type(exc), exc, exc.__traceback__
117
+ _send_exception(exc_type, exc_value, tb)
118
+
119
+
120
+ class capture(contextlib.ContextDecorator):
121
+ """Context manager / decorator: report any exception raised inside the block or
122
+ function, then re-raise it. Use ``@capture()`` on a function instead of wrapping
123
+ its body in a manual ``try/except`` or ``with`` block."""
124
+
125
+ def __enter__(self) -> "capture":
126
+ return self
127
+
128
+ def __exit__(self, exc_type, exc_value, tb) -> Literal[False]:
129
+ if exc_type is not None:
130
+ _send_exception(exc_type, exc_value, tb)
131
+ return False
132
+
133
+
134
+ class ASGIMiddleware:
135
+ """ASGI middleware for FastAPI/Starlette (or any ASGI app): report any exception
136
+ that escapes a request, then re-raise it so the framework's own error handling
137
+ still runs unchanged.
138
+
139
+ Usage::
140
+
141
+ app.add_middleware(devalerts.ASGIMiddleware)
142
+
143
+ Only exceptions that actually reach here (unhandled server errors) get reported —
144
+ routing 404s and raised ``HTTPException``s are already turned into responses by
145
+ the framework before this middleware sees them, same as Sentry's ASGI integration.
146
+ """
147
+
148
+ def __init__(self, app) -> None:
149
+ self.app = app
150
+
151
+ async def __call__(self, scope, receive, send) -> None:
152
+ try:
153
+ await self.app(scope, receive, send)
154
+ except Exception:
155
+ exc_type, exc_value, tb = sys.exc_info()
156
+ # ponytail: fire-and-forget in a thread -- _send_exception is a
157
+ # blocking network call; awaiting it here would stall the event
158
+ # loop (and the error response) for up to _TIMEOUT_SECONDS.
159
+ threading.Thread(
160
+ target=_send_exception, args=(exc_type, exc_value, tb), daemon=True
161
+ ).start()
162
+ raise