devalerts 0.1.4__tar.gz → 0.2.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devalerts
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary: Send unhandled Python exceptions straight to a Telegram chat. No backend, no account, no database — just your own bot token.
5
5
  Keywords: telegram,error-tracking,exception-monitoring,alerts,logging,monitoring,asgi
6
6
  Author: sslinNn
@@ -28,8 +28,12 @@ Description-Content-Type: text/markdown
28
28
  # devalerts
29
29
 
30
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/)
31
32
  [![Python versions](https://img.shields.io/pypi/pyversions/devalerts)](https://pypi.org/project/devalerts/)
32
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/)
33
37
 
34
38
  [Русская версия](README.ru.md)
35
39
 
@@ -73,7 +77,13 @@ chat instead of a log file nobody's watching.
73
77
  2. Message your bot once (or add it to a group) so it's allowed to message you back.
74
78
  3. Get your chat id — message [@userinfobot](https://t.me/userinfobot), or call
75
79
  `https://api.telegram.org/bot<TOKEN>/getUpdates` after step 2 and read `message.chat.id`.
76
- 4. In your app, as early as possible:
80
+ 4. Verify it's wired up correctly before touching any code:
81
+
82
+ ```
83
+ uv run devalerts test --bot-token 123456:ABC-DEF... --chat-id 123456789
84
+ ```
85
+
86
+ 5. In your app, as early as possible:
77
87
 
78
88
  ```python
79
89
  import devalerts
@@ -103,13 +113,48 @@ See what's grouped and what's currently rate-limited:
103
113
  uv run devalerts dashboard
104
114
  ```
105
115
 
116
+ ![devalerts dashboard output](https://raw.githubusercontent.com/sslinNn/devalerts/main/docs/dashboard.svg)
117
+
118
+ Pass `--json` for machine-readable output. Silence a noisy group without
119
+ touching code — the `ID` column accepts any unique prefix:
120
+
121
+ ```
122
+ uv run devalerts mute abc12345
123
+ uv run devalerts unmute abc12345
124
+ uv run devalerts clear abc12345 # or: devalerts clear --all
106
125
  ```
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
126
 
112
- 2 error groups, 1 currently rate-limited.
127
+ Unmuting resends the next occurrence with the accumulated skip count, same
128
+ as a rate-limit window expiring.
129
+
130
+ Error groups that keep piling up while suppressed are chronic: each such
131
+ resend doubles the effective `rate_limit_seconds` for that group (capped at
132
+ 8x), so a crash loop backs itself off instead of paging you every window
133
+ forever. A group that goes quiet and reappears once resets to the base rate
134
+ immediately. The dashboard shows the active multiplier (`● sending ×4`).
135
+
136
+ ## Context: hostname and tags
137
+
138
+ Every alert automatically includes the sending host, so you can tell which
139
+ process/server it came from when one bot serves several:
140
+
141
+ ```python
142
+ devalerts.init(bot_token="...", chat_id=123456789, tags={"env": "production"})
143
+ ```
144
+
145
+ ```
146
+ 🔴 ValueError: boom
147
+ 🖥️ prod-web-2 (env=production)
148
+ ```
149
+
150
+ Add ad-hoc tags to a single call — they override `init()`'s tags on a key
151
+ collision:
152
+
153
+ ```python
154
+ devalerts.report(extra={"request_id": "abc123"})
155
+
156
+ @devalerts.capture(extra={"job": "nightly-sync"})
157
+ def run_job(): ...
113
158
  ```
114
159
 
115
160
  ## Manually reporting a caught exception
@@ -151,11 +196,30 @@ Only exceptions that actually escape as server errors get reported — routing
151
196
  404s and raised `HTTPException`s are already turned into responses by the
152
197
  framework before the middleware sees them.
153
198
 
154
- ## devalerts vs. a full error tracker
199
+ ## Celery
200
+
201
+ Same problem as ASGI apps: `init()`'s excepthook never sees exceptions raised
202
+ inside a task, because Celery catches them itself to record the task's
203
+ `FAILURE` state. Call `init_celery()` in addition to `init()`:
155
204
 
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:
205
+ ```python
206
+ devalerts.init(bot_token="...", chat_id=123456789)
207
+ devalerts.init_celery()
208
+ ```
209
+
210
+ This connects to Celery's `task_failure` signal (fired once a task has
211
+ genuinely failed — retries exhausted or none configured, so retried tasks
212
+ don't spam an alert per attempt) and reports through the same
213
+ grouping/rate-limiting/redaction path as everything else, tagged with the
214
+ task name and id automatically. Requires Celery to already be installed in
215
+ the worker process — it's imported lazily, not a devalerts dependency.
216
+
217
+ ## Why not Sentry?
218
+
219
+ If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
220
+ replacement. It's for the side project, internal tool, or small service that
221
+ doesn't have (and doesn't want) that infrastructure: no account to create, no
222
+ SDK to configure, no server to trust — just a bot token you already control.
159
223
 
160
224
  | | devalerts | Sentry-style tracker |
161
225
  |--------------------------|---------------------|-----------------------|
@@ -165,14 +229,66 @@ service that doesn't have (and doesn't want) that infrastructure yet:
165
229
  | Grouping / rate limiting | yes, local SQLite | yes, server-side |
166
230
  | Search, trends, releases | no | yes |
167
231
 
232
+ ## FAQ
233
+
234
+ **Works with FastAPI / Starlette?**
235
+ Yes — use [`ASGIMiddleware`](#fastapi--starlette--any-asgi-app), since the
236
+ default excepthook never sees request errors.
237
+
238
+ **Works with Docker?**
239
+ Yes, nothing container-specific. Just make sure `~/.devalerts/` (the dedup
240
+ state file) is either writable inside the container or a mounted volume if
241
+ you want dedup state to survive restarts — it self-recreates otherwise.
242
+
243
+ **Works with threads?**
244
+ Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
245
+
246
+ **Works with Celery / background workers?**
247
+ Yes — call [`init_celery()`](#celery) in addition to `init()` to catch
248
+ exceptions raised inside tasks, which the excepthook alone won't see.
249
+
250
+ **Works on Windows / Linux / macOS?**
251
+ Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
252
+ paths.
253
+
254
+ ## Privacy & Security
255
+
256
+ - The only network call devalerts makes is to `api.telegram.org` — no
257
+ telemetry, no analytics, nothing else phones home.
258
+ - No third-party server and no devalerts-run backend — messages go straight
259
+ from your process to your own Telegram bot.
260
+ - No accounts, no signup, no API key beyond the bot token you create and
261
+ control yourself.
262
+ - Basic secret redaction only (a few common token/key patterns) — do not
263
+ rely on this for sensitive production data; scrub what you can before it
264
+ ever reaches an exception message.
265
+ - If Telegram delivery fails after retrying, the alert (already redacted, if
266
+ `redact=True`) is appended to `~/.devalerts/failed.log` instead of being
267
+ dropped — clean it up like any other local log file.
268
+
168
269
  ## What this does NOT do (by design)
169
270
 
170
271
  - Grouping/rate limiting is local and in-process only (SQLite file, no
171
272
  server) — the dashboard is a CLI table, not a web UI.
172
273
  - 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.
274
+
275
+ ## Roadmap
276
+
277
+ - Web dashboard (hosted, optional — the local CLI dashboard stays either way)
278
+ - Slack delivery
279
+ - Discord delivery
280
+ - Email delivery
281
+
282
+ ## Development
283
+
284
+ uv sync --group dev
285
+ uv run pre-commit install
286
+
287
+ `pre-commit` runs `ruff check`, `ruff format`, and `mypy` before each commit.
288
+ Run the full check manually with:
289
+
290
+ uv run pytest
291
+ uv run pre-commit run --all-files
176
292
 
177
293
  ## License
178
294
 
@@ -0,0 +1,268 @@
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. Verify it's wired up correctly before touching any code:
54
+
55
+ ```
56
+ uv run devalerts test --bot-token 123456:ABC-DEF... --chat-id 123456789
57
+ ```
58
+
59
+ 5. In your app, as early as possible:
60
+
61
+ ```python
62
+ import devalerts
63
+
64
+ devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
65
+ ```
66
+
67
+ That's it — any unhandled exception (including ones raised in threads) now
68
+ also lands in your Telegram chat.
69
+
70
+ ## Grouping, rate limiting, and the dashboard
71
+
72
+ Exceptions are grouped by fingerprint (exception type + file + line where it
73
+ was raised) in a local SQLite file (`~/.devalerts/state.db`). Each group
74
+ sends at most one Telegram message per `rate_limit_seconds` (default 300);
75
+ repeats inside that window are counted but not sent, and the next message
76
+ for that group says how many were skipped. Old groups (untouched for 7 days)
77
+ are pruned automatically. Configure the window via `init()`:
78
+
79
+ ```python
80
+ devalerts.init(bot_token="...", chat_id=123456789, rate_limit_seconds=60)
81
+ ```
82
+
83
+ See what's grouped and what's currently rate-limited:
84
+
85
+ ```
86
+ uv run devalerts dashboard
87
+ ```
88
+
89
+ ![devalerts dashboard output](https://raw.githubusercontent.com/sslinNn/devalerts/main/docs/dashboard.svg)
90
+
91
+ Pass `--json` for machine-readable output. Silence a noisy group without
92
+ touching code — the `ID` column accepts any unique prefix:
93
+
94
+ ```
95
+ uv run devalerts mute abc12345
96
+ uv run devalerts unmute abc12345
97
+ uv run devalerts clear abc12345 # or: devalerts clear --all
98
+ ```
99
+
100
+ Unmuting resends the next occurrence with the accumulated skip count, same
101
+ as a rate-limit window expiring.
102
+
103
+ Error groups that keep piling up while suppressed are chronic: each such
104
+ resend doubles the effective `rate_limit_seconds` for that group (capped at
105
+ 8x), so a crash loop backs itself off instead of paging you every window
106
+ forever. A group that goes quiet and reappears once resets to the base rate
107
+ immediately. The dashboard shows the active multiplier (`● sending ×4`).
108
+
109
+ ## Context: hostname and tags
110
+
111
+ Every alert automatically includes the sending host, so you can tell which
112
+ process/server it came from when one bot serves several:
113
+
114
+ ```python
115
+ devalerts.init(bot_token="...", chat_id=123456789, tags={"env": "production"})
116
+ ```
117
+
118
+ ```
119
+ 🔴 ValueError: boom
120
+ 🖥️ prod-web-2 (env=production)
121
+ ```
122
+
123
+ Add ad-hoc tags to a single call — they override `init()`'s tags on a key
124
+ collision:
125
+
126
+ ```python
127
+ devalerts.report(extra={"request_id": "abc123"})
128
+
129
+ @devalerts.capture(extra={"job": "nightly-sync"})
130
+ def run_job(): ...
131
+ ```
132
+
133
+ ## Manually reporting a caught exception
134
+
135
+ ```python
136
+ try:
137
+ risky_call()
138
+ except Exception:
139
+ devalerts.report() # sends the currently-handled exception
140
+ ```
141
+
142
+ or:
143
+
144
+ ```python
145
+ with devalerts.capture():
146
+ risky_call() # reports on exception, then re-raises
147
+ ```
148
+
149
+ `capture` also works as a decorator, so you don't need to touch a function's
150
+ body at all:
151
+
152
+ ```python
153
+ @devalerts.capture()
154
+ def risky_call():
155
+ ...
156
+ ```
157
+
158
+ ## FastAPI / Starlette / any ASGI app
159
+
160
+ `init()`'s excepthook won't see request errors — the framework already catches
161
+ them internally to return a 500 response, so nothing "unhandled" ever reaches
162
+ the process. Use the ASGI middleware instead:
163
+
164
+ ```python
165
+ app.add_middleware(devalerts.ASGIMiddleware)
166
+ ```
167
+
168
+ Only exceptions that actually escape as server errors get reported — routing
169
+ 404s and raised `HTTPException`s are already turned into responses by the
170
+ framework before the middleware sees them.
171
+
172
+ ## Celery
173
+
174
+ Same problem as ASGI apps: `init()`'s excepthook never sees exceptions raised
175
+ inside a task, because Celery catches them itself to record the task's
176
+ `FAILURE` state. Call `init_celery()` in addition to `init()`:
177
+
178
+ ```python
179
+ devalerts.init(bot_token="...", chat_id=123456789)
180
+ devalerts.init_celery()
181
+ ```
182
+
183
+ This connects to Celery's `task_failure` signal (fired once a task has
184
+ genuinely failed — retries exhausted or none configured, so retried tasks
185
+ don't spam an alert per attempt) and reports through the same
186
+ grouping/rate-limiting/redaction path as everything else, tagged with the
187
+ task name and id automatically. Requires Celery to already be installed in
188
+ the worker process — it's imported lazily, not a devalerts dependency.
189
+
190
+ ## Why not Sentry?
191
+
192
+ If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
193
+ replacement. It's for the side project, internal tool, or small service that
194
+ doesn't have (and doesn't want) that infrastructure: no account to create, no
195
+ SDK to configure, no server to trust — just a bot token you already control.
196
+
197
+ | | devalerts | Sentry-style tracker |
198
+ |--------------------------|---------------------|-----------------------|
199
+ | Setup | one bot token | account + project + SDK config |
200
+ | Backend | none — Telegram only | hosted or self-hosted service |
201
+ | Where alerts land | your Telegram chat | a web dashboard |
202
+ | Grouping / rate limiting | yes, local SQLite | yes, server-side |
203
+ | Search, trends, releases | no | yes |
204
+
205
+ ## FAQ
206
+
207
+ **Works with FastAPI / Starlette?**
208
+ Yes — use [`ASGIMiddleware`](#fastapi--starlette--any-asgi-app), since the
209
+ default excepthook never sees request errors.
210
+
211
+ **Works with Docker?**
212
+ Yes, nothing container-specific. Just make sure `~/.devalerts/` (the dedup
213
+ state file) is either writable inside the container or a mounted volume if
214
+ you want dedup state to survive restarts — it self-recreates otherwise.
215
+
216
+ **Works with threads?**
217
+ Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
218
+
219
+ **Works with Celery / background workers?**
220
+ Yes — call [`init_celery()`](#celery) in addition to `init()` to catch
221
+ exceptions raised inside tasks, which the excepthook alone won't see.
222
+
223
+ **Works on Windows / Linux / macOS?**
224
+ Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
225
+ paths.
226
+
227
+ ## Privacy & Security
228
+
229
+ - The only network call devalerts makes is to `api.telegram.org` — no
230
+ telemetry, no analytics, nothing else phones home.
231
+ - No third-party server and no devalerts-run backend — messages go straight
232
+ from your process to your own Telegram bot.
233
+ - No accounts, no signup, no API key beyond the bot token you create and
234
+ control yourself.
235
+ - Basic secret redaction only (a few common token/key patterns) — do not
236
+ rely on this for sensitive production data; scrub what you can before it
237
+ ever reaches an exception message.
238
+ - If Telegram delivery fails after retrying, the alert (already redacted, if
239
+ `redact=True`) is appended to `~/.devalerts/failed.log` instead of being
240
+ dropped — clean it up like any other local log file.
241
+
242
+ ## What this does NOT do (by design)
243
+
244
+ - Grouping/rate limiting is local and in-process only (SQLite file, no
245
+ server) — the dashboard is a CLI table, not a web UI.
246
+ - No backend, no accounts — each user runs their own bot.
247
+
248
+ ## Roadmap
249
+
250
+ - Web dashboard (hosted, optional — the local CLI dashboard stays either way)
251
+ - Slack delivery
252
+ - Discord delivery
253
+ - Email delivery
254
+
255
+ ## Development
256
+
257
+ uv sync --group dev
258
+ uv run pre-commit install
259
+
260
+ `pre-commit` runs `ruff check`, `ruff format`, and `mypy` before each commit.
261
+ Run the full check manually with:
262
+
263
+ uv run pytest
264
+ uv run pre-commit run --all-files
265
+
266
+ ## License
267
+
268
+ MIT — see [LICENSE](LICENSE).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devalerts"
3
- version = "0.1.4"
3
+ version = "0.2.0"
4
4
  description = "Send unhandled Python exceptions straight to a Telegram chat. No backend, no account, no database — just your own bot token."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -43,8 +43,20 @@ Repository = "https://github.com/sslinNn/devalerts"
43
43
  devalerts = "devalerts.cli:main"
44
44
 
45
45
  [build-system]
46
- requires = ["uv_build>=0.10.2,<0.11.0"]
46
+ requires = ["uv_build>=0.10.2,<0.12.0"]
47
47
  build-backend = "uv_build"
48
48
 
49
49
  [dependency-groups]
50
- dev = []
50
+ dev = [
51
+ "celery>=5.3.0",
52
+ "mypy>=1.19.1",
53
+ "pre-commit>=4.3.0",
54
+ "pytest>=8.4.2",
55
+ "ruff>=0.15.22",
56
+ ]
57
+
58
+ [tool.ruff]
59
+ target-version = "py39"
60
+
61
+ [tool.ruff.lint]
62
+ extend-select = ["BLE"]
@@ -5,29 +5,61 @@ from __future__ import annotations
5
5
  import contextlib
6
6
  import sys
7
7
  import threading
8
+ from types import TracebackType
9
+ from typing import Callable, Literal, Optional, TypedDict
8
10
 
9
11
  from ._alert import _format_alert, _redact
12
+ from ._celery import init_celery
10
13
  from ._store import _DEFAULT_RATE_LIMIT_SECONDS, _fingerprint, _should_send
11
14
  from ._telegram import _send_telegram_message
12
15
 
13
- __all__: list[str] = ["init", "report", "capture", "ASGIMiddleware"]
16
+ __all__: list[str] = ["init", "report", "capture", "ASGIMiddleware", "init_celery"]
14
17
 
15
- _state = {
18
+ _ExceptHook = Callable[
19
+ [type[BaseException], BaseException, Optional[TracebackType]], object
20
+ ]
21
+ _ThreadingExceptHook = Callable[[threading.ExceptHookArgs], object]
22
+
23
+
24
+ class _State(TypedDict):
25
+ bot_token: str | None
26
+ chat_id: int | str | None
27
+ redact: bool
28
+ rate_limit_seconds: int
29
+ tags: dict[str, str]
30
+ # Seeded with the real default hooks below (never None) -- _excepthook/
31
+ # _threading_excepthook can only ever fire after init() has replaced
32
+ # sys.excepthook/threading.excepthook, by which point these are always
33
+ # set, so keeping them non-Optional avoids an unreachable None check.
34
+ prev_excepthook: _ExceptHook
35
+ prev_threading_excepthook: _ThreadingExceptHook
36
+
37
+
38
+ _state: _State = {
16
39
  "bot_token": None,
17
40
  "chat_id": None,
18
41
  "redact": True,
19
42
  "rate_limit_seconds": _DEFAULT_RATE_LIMIT_SECONDS,
20
- "prev_excepthook": None,
21
- "prev_threading_excepthook": None,
43
+ "tags": {},
44
+ "prev_excepthook": sys.excepthook,
45
+ "prev_threading_excepthook": threading.excepthook,
22
46
  }
23
47
 
24
48
 
25
- def _send_exception(exc_type, exc_value, tb) -> None:
49
+ def _send_exception(
50
+ exc_type, exc_value, tb, extra: dict[str, str] | None = None
51
+ ) -> None:
52
+ if _state["bot_token"] is None or _state["chat_id"] is None:
53
+ print("devalerts: init() was not called, dropping alert", file=sys.stderr)
54
+ return
26
55
  fingerprint, location = _fingerprint(exc_type, tb)
27
- send, skipped = _should_send(fingerprint, exc_type.__name__, location, _state["rate_limit_seconds"])
56
+ send, skipped = _should_send(
57
+ fingerprint, exc_type.__name__, location, _state["rate_limit_seconds"]
58
+ )
28
59
  if not send:
29
60
  return
30
- message = _format_alert(exc_type, exc_value, tb, skipped=skipped)
61
+ tags = {**_state["tags"], **(extra or {})}
62
+ message = _format_alert(exc_type, exc_value, tb, skipped=skipped, tags=tags)
31
63
  if _state["redact"]:
32
64
  message = _redact(message)
33
65
  _send_telegram_message(_state["bot_token"], _state["chat_id"], message)
@@ -38,7 +70,10 @@ def _excepthook(exc_type, exc_value, tb) -> None:
38
70
  try:
39
71
  _send_exception(exc_type, exc_value, tb)
40
72
  except Exception as error: # noqa: BLE001 - crash handler must never raise
41
- print(f"devalerts: internal error while sending alert: {error}", file=sys.stderr)
73
+ print(
74
+ f"devalerts: internal error while sending alert: {error}",
75
+ file=sys.stderr,
76
+ )
42
77
  _state["prev_excepthook"](exc_type, exc_value, tb)
43
78
 
44
79
 
@@ -46,22 +81,26 @@ def _threading_excepthook(args) -> None:
46
81
  try:
47
82
  _send_exception(args.exc_type, args.exc_value, args.exc_traceback)
48
83
  except Exception as error: # noqa: BLE001
49
- print(f"devalerts: internal error while sending alert: {error}", file=sys.stderr)
84
+ print(
85
+ f"devalerts: internal error while sending alert: {error}", file=sys.stderr
86
+ )
50
87
  _state["prev_threading_excepthook"](args)
51
88
 
52
89
 
53
90
  def init(
54
91
  bot_token: str,
55
- chat_id,
92
+ chat_id: int | str,
56
93
  *,
57
94
  redact: bool = True,
58
95
  rate_limit_seconds: int = _DEFAULT_RATE_LIMIT_SECONDS,
96
+ tags: dict[str, str] | None = None,
59
97
  ) -> None:
60
98
  """Install a global exception hook that sends unhandled exceptions to Telegram."""
61
99
  _state["bot_token"] = bot_token
62
100
  _state["chat_id"] = chat_id
63
101
  _state["redact"] = redact
64
102
  _state["rate_limit_seconds"] = rate_limit_seconds
103
+ _state["tags"] = tags or {}
65
104
  # ponytail: guard against calling init() twice capturing our own hook as
66
105
  # "previous" -- that would make _excepthook chain to itself and recurse
67
106
  # forever on the next crash, violating "must never raise".
@@ -73,15 +112,19 @@ def init(
73
112
  threading.excepthook = _threading_excepthook
74
113
 
75
114
 
76
- def report(exc: BaseException | None = None) -> None:
115
+ def report(
116
+ exc: BaseException | None = None, *, extra: dict[str, str] | None = None
117
+ ) -> None:
77
118
  """Manually send a caught exception to Telegram."""
78
119
  if exc is None:
79
120
  exc_type, exc_value, tb = sys.exc_info()
80
121
  if exc_type is None:
81
- raise RuntimeError("report() requires an active exception or an exc argument")
122
+ raise RuntimeError(
123
+ "report() requires an active exception or an exc argument"
124
+ )
82
125
  else:
83
126
  exc_type, exc_value, tb = type(exc), exc, exc.__traceback__
84
- _send_exception(exc_type, exc_value, tb)
127
+ _send_exception(exc_type, exc_value, tb, extra=extra)
85
128
 
86
129
 
87
130
  class capture(contextlib.ContextDecorator):
@@ -89,12 +132,15 @@ class capture(contextlib.ContextDecorator):
89
132
  function, then re-raise it. Use ``@capture()`` on a function instead of wrapping
90
133
  its body in a manual ``try/except`` or ``with`` block."""
91
134
 
135
+ def __init__(self, *, extra: dict[str, str] | None = None) -> None:
136
+ self._extra = extra
137
+
92
138
  def __enter__(self) -> "capture":
93
139
  return self
94
140
 
95
- def __exit__(self, exc_type, exc_value, tb) -> bool:
141
+ def __exit__(self, exc_type, exc_value, tb) -> Literal[False]:
96
142
  if exc_type is not None:
97
- _send_exception(exc_type, exc_value, tb)
143
+ _send_exception(exc_type, exc_value, tb, extra=self._extra)
98
144
  return False
99
145
 
100
146
 
@@ -3,13 +3,27 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
+ import socket
6
7
  import traceback
7
8
 
8
9
  _MAX_MESSAGE_LENGTH = 4096
9
10
 
10
11
 
11
- def _format_alert(exc_type, exc_value, tb, skipped: int = 0) -> str:
12
- header = f"\U0001F534 {exc_type.__name__}: {exc_value}"
12
+ def _format_context(tags: dict[str, str] | None) -> str:
13
+ try:
14
+ hostname = socket.gethostname()
15
+ except OSError:
16
+ hostname = "unknown-host"
17
+ line = f"🖥️ {hostname}"
18
+ if tags:
19
+ line += " (" + ", ".join(f"{key}={value}" for key, value in tags.items()) + ")"
20
+ return line
21
+
22
+
23
+ def _format_alert(
24
+ exc_type, exc_value, tb, skipped: int = 0, tags: dict[str, str] | None = None
25
+ ) -> str:
26
+ header = f"\U0001f534 {exc_type.__name__}: {exc_value}\n{_format_context(tags)}"
13
27
  if skipped:
14
28
  header += f"\n⚠️ Повторилась ещё {skipped} раз(а) с последнего алерта"
15
29
  body = "".join(traceback.format_exception(exc_type, exc_value, tb))