devalerts 0.2.2__tar.gz → 0.3.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.
- {devalerts-0.2.2 → devalerts-0.3.0}/PKG-INFO +73 -13
- {devalerts-0.2.2 → devalerts-0.3.0}/README.md +71 -11
- {devalerts-0.2.2 → devalerts-0.3.0}/pyproject.toml +2 -2
- {devalerts-0.2.2 → devalerts-0.3.0}/src/devalerts/__init__.py +160 -13
- devalerts-0.3.0/src/devalerts/_alert.py +152 -0
- devalerts-0.3.0/src/devalerts/_blame.py +82 -0
- devalerts-0.3.0/src/devalerts/_slack.py +42 -0
- {devalerts-0.2.2 → devalerts-0.3.0}/src/devalerts/_store.py +12 -3
- {devalerts-0.2.2 → devalerts-0.3.0}/src/devalerts/cli.py +46 -14
- devalerts-0.2.2/src/devalerts/_alert.py +0 -71
- {devalerts-0.2.2 → devalerts-0.3.0}/LICENSE +0 -0
- {devalerts-0.2.2 → devalerts-0.3.0}/src/devalerts/_celery.py +0 -0
- {devalerts-0.2.2 → devalerts-0.3.0}/src/devalerts/_telegram.py +0 -0
- {devalerts-0.2.2 → devalerts-0.3.0}/src/devalerts/py.typed +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devalerts
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Send unhandled Python exceptions straight to a Telegram chat. No backend, no account, no database — just your own bot token.
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Send unhandled Python exceptions straight to a Telegram chat (or Slack). No backend, no account, no database — just your own bot token or webhook URL.
|
|
5
5
|
Keywords: telegram,error-tracking,exception-monitoring,alerts,logging,monitoring,asgi
|
|
6
6
|
Author: sslinNn
|
|
7
7
|
Author-email: sslinNn <morison1991@mail.ru>
|
|
@@ -28,7 +28,7 @@ Description-Content-Type: text/markdown
|
|
|
28
28
|
# devalerts
|
|
29
29
|
|
|
30
30
|
[](https://pypi.org/project/devalerts/)
|
|
31
|
-
[](https://pepy.tech/projects/devalerts)
|
|
32
32
|
[](https://pypi.org/project/devalerts/)
|
|
33
33
|
[](LICENSE)
|
|
34
34
|
[](https://github.com/sslinNn/devalerts/actions/workflows/test.yml)
|
|
@@ -37,9 +37,9 @@ Description-Content-Type: text/markdown
|
|
|
37
37
|
|
|
38
38
|
[Русская версия](README.ru.md)
|
|
39
39
|
|
|
40
|
-
Send unhandled Python exceptions straight to a Telegram chat
|
|
41
|
-
they happen, on your phone. No backend, no account, no database —
|
|
42
|
-
own bot token.
|
|
40
|
+
Send unhandled Python exceptions straight to a Telegram chat (or Slack) —
|
|
41
|
+
the moment they happen, on your phone. No backend, no account, no database —
|
|
42
|
+
just your own bot token or webhook URL.
|
|
43
43
|
|
|
44
44
|
```python
|
|
45
45
|
import devalerts
|
|
@@ -51,6 +51,8 @@ That's the whole setup. Two minutes with [@BotFather](https://t.me/BotFather)
|
|
|
51
51
|
and every unhandled crash — including ones raised in threads — lands in your
|
|
52
52
|
chat instead of a log file nobody's watching.
|
|
53
53
|
|
|
54
|
+

|
|
55
|
+
|
|
54
56
|
## Why devalerts
|
|
55
57
|
|
|
56
58
|
- **Zero infrastructure.** No SaaS signup, no ingestion server, no API key
|
|
@@ -230,6 +232,60 @@ grouping/rate-limiting/redaction path as everything else, tagged with the
|
|
|
230
232
|
task name and id automatically. Requires Celery to already be installed in
|
|
231
233
|
the worker process — it's imported lazily, not a devalerts dependency.
|
|
232
234
|
|
|
235
|
+
## Logged (not raised) exceptions
|
|
236
|
+
|
|
237
|
+
`init()`'s excepthook only ever sees exceptions that stay unhandled — but most
|
|
238
|
+
real errors are caught and logged instead: `logger.exception(...)` inside a
|
|
239
|
+
`try/except` never propagates anywhere. Attach `LogHandler` to catch those too:
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
import logging
|
|
243
|
+
|
|
244
|
+
logging.getLogger().addHandler(devalerts.LogHandler())
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
A record with `exc_info` (`logger.exception()`, or `logger.error(...,
|
|
248
|
+
exc_info=True)`) is reported like a full exception alert — grouped by the
|
|
249
|
+
same fingerprint an unhandled instance of it would use, so logging it and
|
|
250
|
+
then re-raising sends one alert, not two. A plain `logger.error("message")`
|
|
251
|
+
with no exception attached is reported as a short text alert, grouped by
|
|
252
|
+
logger name + level + message. Defaults to `level=logging.ERROR`; pass
|
|
253
|
+
`extra={...}` for tags added to every alert from this handler.
|
|
254
|
+
|
|
255
|
+
## Blame
|
|
256
|
+
|
|
257
|
+
Curious *who* wrote the line that just blew up? `blame=True` runs `git blame`
|
|
258
|
+
on it and adds the author, short commit hash, and date to the alert:
|
|
259
|
+
|
|
260
|
+
```python
|
|
261
|
+
devalerts.init(bot_token="...", chat_id=123456789, blame=True)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
```
|
|
265
|
+
🕵️ blame: sslinNn · a1b2c3d · 2026-07-15 (3d ago)
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Best-effort and silent about it — no git installed, no repo (a container
|
|
269
|
+
image without `.git`), or the line isn't committed yet all just skip the
|
|
270
|
+
line instead of failing the alert. Off by default; opt in with `blame=True`.
|
|
271
|
+
|
|
272
|
+
## Slack
|
|
273
|
+
|
|
274
|
+
Prefer Slack, or want both? Pass a Slack incoming webhook URL alongside or
|
|
275
|
+
instead of `bot_token`/`chat_id` — every configured channel gets every alert:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
devalerts.init(slack_webhook_url="https://hooks.slack.com/services/...")
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Create one at [api.slack.com/apps](https://api.slack.com/apps) → your app →
|
|
282
|
+
**Incoming Webhooks**. Same grouping/rate-limiting/redaction/blame path as
|
|
283
|
+
Telegram, formatted as Slack mrkdwn instead of HTML. Verify it's wired up:
|
|
284
|
+
|
|
285
|
+
```
|
|
286
|
+
uv run devalerts test --slack-webhook-url https://hooks.slack.com/services/...
|
|
287
|
+
```
|
|
288
|
+
|
|
233
289
|
## Why not Sentry?
|
|
234
290
|
|
|
235
291
|
If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
|
|
@@ -263,22 +319,27 @@ Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
|
|
|
263
319
|
Yes — call [`init_celery()`](#celery) in addition to `init()` to catch
|
|
264
320
|
exceptions raised inside tasks, which the excepthook alone won't see.
|
|
265
321
|
|
|
322
|
+
**Catches exceptions I log but don't re-raise?**
|
|
323
|
+
Yes — add [`LogHandler`](#logged-not-raised-exceptions) to a logger; the
|
|
324
|
+
excepthook alone never sees a caught-and-logged exception.
|
|
325
|
+
|
|
266
326
|
**Works on Windows / Linux / macOS?**
|
|
267
327
|
Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
|
|
268
328
|
paths.
|
|
269
329
|
|
|
270
330
|
## Privacy & Security
|
|
271
331
|
|
|
272
|
-
- The only network
|
|
273
|
-
telemetry, no analytics,
|
|
332
|
+
- The only network calls devalerts makes are to `api.telegram.org` and/or
|
|
333
|
+
your configured Slack incoming webhook — no telemetry, no analytics,
|
|
334
|
+
nothing else phones home.
|
|
274
335
|
- No third-party server and no devalerts-run backend — messages go straight
|
|
275
|
-
from your process to your own Telegram bot.
|
|
276
|
-
- No accounts, no signup, no API key beyond the bot token
|
|
277
|
-
control yourself.
|
|
336
|
+
from your process to your own Telegram bot / Slack webhook.
|
|
337
|
+
- No accounts, no signup, no API key beyond the bot token / webhook URL you
|
|
338
|
+
create and control yourself.
|
|
278
339
|
- Basic secret redaction only (a few common token/key patterns) — do not
|
|
279
340
|
rely on this for sensitive production data; scrub what you can before it
|
|
280
341
|
ever reaches an exception message.
|
|
281
|
-
- If
|
|
342
|
+
- If delivery fails after retrying, the alert (already redacted, if
|
|
282
343
|
`redact=True`) is appended to `~/.devalerts/failed.log` instead of being
|
|
283
344
|
dropped — clean it up like any other local log file.
|
|
284
345
|
|
|
@@ -291,7 +352,6 @@ paths.
|
|
|
291
352
|
## Roadmap
|
|
292
353
|
|
|
293
354
|
- Web dashboard (hosted, optional — the local CLI dashboard stays either way)
|
|
294
|
-
- Slack delivery
|
|
295
355
|
- Discord delivery
|
|
296
356
|
- Email delivery
|
|
297
357
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# devalerts
|
|
2
2
|
|
|
3
3
|
[](https://pypi.org/project/devalerts/)
|
|
4
|
-
[](https://pepy.tech/projects/devalerts)
|
|
5
5
|
[](https://pypi.org/project/devalerts/)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://github.com/sslinNn/devalerts/actions/workflows/test.yml)
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
[Русская версия](README.ru.md)
|
|
12
12
|
|
|
13
|
-
Send unhandled Python exceptions straight to a Telegram chat
|
|
14
|
-
they happen, on your phone. No backend, no account, no database —
|
|
15
|
-
own bot token.
|
|
13
|
+
Send unhandled Python exceptions straight to a Telegram chat (or Slack) —
|
|
14
|
+
the moment they happen, on your phone. No backend, no account, no database —
|
|
15
|
+
just your own bot token or webhook URL.
|
|
16
16
|
|
|
17
17
|
```python
|
|
18
18
|
import devalerts
|
|
@@ -24,6 +24,8 @@ That's the whole setup. Two minutes with [@BotFather](https://t.me/BotFather)
|
|
|
24
24
|
and every unhandled crash — including ones raised in threads — lands in your
|
|
25
25
|
chat instead of a log file nobody's watching.
|
|
26
26
|
|
|
27
|
+

|
|
28
|
+
|
|
27
29
|
## Why devalerts
|
|
28
30
|
|
|
29
31
|
- **Zero infrastructure.** No SaaS signup, no ingestion server, no API key
|
|
@@ -203,6 +205,60 @@ grouping/rate-limiting/redaction path as everything else, tagged with the
|
|
|
203
205
|
task name and id automatically. Requires Celery to already be installed in
|
|
204
206
|
the worker process — it's imported lazily, not a devalerts dependency.
|
|
205
207
|
|
|
208
|
+
## Logged (not raised) exceptions
|
|
209
|
+
|
|
210
|
+
`init()`'s excepthook only ever sees exceptions that stay unhandled — but most
|
|
211
|
+
real errors are caught and logged instead: `logger.exception(...)` inside a
|
|
212
|
+
`try/except` never propagates anywhere. Attach `LogHandler` to catch those too:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
import logging
|
|
216
|
+
|
|
217
|
+
logging.getLogger().addHandler(devalerts.LogHandler())
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
A record with `exc_info` (`logger.exception()`, or `logger.error(...,
|
|
221
|
+
exc_info=True)`) is reported like a full exception alert — grouped by the
|
|
222
|
+
same fingerprint an unhandled instance of it would use, so logging it and
|
|
223
|
+
then re-raising sends one alert, not two. A plain `logger.error("message")`
|
|
224
|
+
with no exception attached is reported as a short text alert, grouped by
|
|
225
|
+
logger name + level + message. Defaults to `level=logging.ERROR`; pass
|
|
226
|
+
`extra={...}` for tags added to every alert from this handler.
|
|
227
|
+
|
|
228
|
+
## Blame
|
|
229
|
+
|
|
230
|
+
Curious *who* wrote the line that just blew up? `blame=True` runs `git blame`
|
|
231
|
+
on it and adds the author, short commit hash, and date to the alert:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
devalerts.init(bot_token="...", chat_id=123456789, blame=True)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
```
|
|
238
|
+
🕵️ blame: sslinNn · a1b2c3d · 2026-07-15 (3d ago)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Best-effort and silent about it — no git installed, no repo (a container
|
|
242
|
+
image without `.git`), or the line isn't committed yet all just skip the
|
|
243
|
+
line instead of failing the alert. Off by default; opt in with `blame=True`.
|
|
244
|
+
|
|
245
|
+
## Slack
|
|
246
|
+
|
|
247
|
+
Prefer Slack, or want both? Pass a Slack incoming webhook URL alongside or
|
|
248
|
+
instead of `bot_token`/`chat_id` — every configured channel gets every alert:
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
devalerts.init(slack_webhook_url="https://hooks.slack.com/services/...")
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Create one at [api.slack.com/apps](https://api.slack.com/apps) → your app →
|
|
255
|
+
**Incoming Webhooks**. Same grouping/rate-limiting/redaction/blame path as
|
|
256
|
+
Telegram, formatted as Slack mrkdwn instead of HTML. Verify it's wired up:
|
|
257
|
+
|
|
258
|
+
```
|
|
259
|
+
uv run devalerts test --slack-webhook-url https://hooks.slack.com/services/...
|
|
260
|
+
```
|
|
261
|
+
|
|
206
262
|
## Why not Sentry?
|
|
207
263
|
|
|
208
264
|
If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
|
|
@@ -236,22 +292,27 @@ Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
|
|
|
236
292
|
Yes — call [`init_celery()`](#celery) in addition to `init()` to catch
|
|
237
293
|
exceptions raised inside tasks, which the excepthook alone won't see.
|
|
238
294
|
|
|
295
|
+
**Catches exceptions I log but don't re-raise?**
|
|
296
|
+
Yes — add [`LogHandler`](#logged-not-raised-exceptions) to a logger; the
|
|
297
|
+
excepthook alone never sees a caught-and-logged exception.
|
|
298
|
+
|
|
239
299
|
**Works on Windows / Linux / macOS?**
|
|
240
300
|
Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
|
|
241
301
|
paths.
|
|
242
302
|
|
|
243
303
|
## Privacy & Security
|
|
244
304
|
|
|
245
|
-
- The only network
|
|
246
|
-
telemetry, no analytics,
|
|
305
|
+
- The only network calls devalerts makes are to `api.telegram.org` and/or
|
|
306
|
+
your configured Slack incoming webhook — no telemetry, no analytics,
|
|
307
|
+
nothing else phones home.
|
|
247
308
|
- No third-party server and no devalerts-run backend — messages go straight
|
|
248
|
-
from your process to your own Telegram bot.
|
|
249
|
-
- No accounts, no signup, no API key beyond the bot token
|
|
250
|
-
control yourself.
|
|
309
|
+
from your process to your own Telegram bot / Slack webhook.
|
|
310
|
+
- No accounts, no signup, no API key beyond the bot token / webhook URL you
|
|
311
|
+
create and control yourself.
|
|
251
312
|
- Basic secret redaction only (a few common token/key patterns) — do not
|
|
252
313
|
rely on this for sensitive production data; scrub what you can before it
|
|
253
314
|
ever reaches an exception message.
|
|
254
|
-
- If
|
|
315
|
+
- If delivery fails after retrying, the alert (already redacted, if
|
|
255
316
|
`redact=True`) is appended to `~/.devalerts/failed.log` instead of being
|
|
256
317
|
dropped — clean it up like any other local log file.
|
|
257
318
|
|
|
@@ -264,7 +325,6 @@ paths.
|
|
|
264
325
|
## Roadmap
|
|
265
326
|
|
|
266
327
|
- Web dashboard (hosted, optional — the local CLI dashboard stays either way)
|
|
267
|
-
- Slack delivery
|
|
268
328
|
- Discord delivery
|
|
269
329
|
- Email delivery
|
|
270
330
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "devalerts"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "Send unhandled Python exceptions straight to a Telegram chat. No backend, no account, no database — just your own bot token."
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
description = "Send unhandled Python exceptions straight to a Telegram chat (or Slack). No backend, no account, no database — just your own bot token or webhook URL."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
license-files = ["LICENSE"]
|
|
@@ -1,19 +1,40 @@
|
|
|
1
|
-
"""Send unhandled Python exceptions straight to a Telegram chat."""
|
|
1
|
+
"""Send unhandled Python exceptions straight to a Telegram chat and/or Slack."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import contextlib
|
|
6
|
+
import logging
|
|
6
7
|
import sys
|
|
7
8
|
import threading
|
|
8
9
|
from types import TracebackType
|
|
9
10
|
from typing import Callable, Literal, Optional, TypedDict
|
|
10
11
|
|
|
11
|
-
from ._alert import
|
|
12
|
+
from ._alert import (
|
|
13
|
+
_format_alert,
|
|
14
|
+
_format_alert_slack,
|
|
15
|
+
_format_log_alert,
|
|
16
|
+
_format_log_alert_slack,
|
|
17
|
+
_redact,
|
|
18
|
+
)
|
|
19
|
+
from ._blame import _git_blame_for_traceback
|
|
12
20
|
from ._celery import init_celery
|
|
13
|
-
from .
|
|
21
|
+
from ._slack import _send_slack_message
|
|
22
|
+
from ._store import (
|
|
23
|
+
_DEFAULT_RATE_LIMIT_SECONDS,
|
|
24
|
+
_fingerprint,
|
|
25
|
+
_fingerprint_log,
|
|
26
|
+
_should_send,
|
|
27
|
+
)
|
|
14
28
|
from ._telegram import _send_telegram_message
|
|
15
29
|
|
|
16
|
-
__all__: list[str] = [
|
|
30
|
+
__all__: list[str] = [
|
|
31
|
+
"init",
|
|
32
|
+
"report",
|
|
33
|
+
"capture",
|
|
34
|
+
"ASGIMiddleware",
|
|
35
|
+
"LogHandler",
|
|
36
|
+
"init_celery",
|
|
37
|
+
]
|
|
17
38
|
|
|
18
39
|
_ExceptHook = Callable[
|
|
19
40
|
[type[BaseException], BaseException, Optional[TracebackType]], object
|
|
@@ -24,9 +45,11 @@ _ThreadingExceptHook = Callable[[threading.ExceptHookArgs], object]
|
|
|
24
45
|
class _State(TypedDict):
|
|
25
46
|
bot_token: str | None
|
|
26
47
|
chat_id: int | str | None
|
|
48
|
+
slack_webhook_url: str | None
|
|
27
49
|
redact: bool
|
|
28
50
|
rate_limit_seconds: int
|
|
29
51
|
tags: dict[str, str]
|
|
52
|
+
blame: bool
|
|
30
53
|
# Seeded with the real default hooks below (never None) -- _excepthook/
|
|
31
54
|
# _threading_excepthook can only ever fire after init() has replaced
|
|
32
55
|
# sys.excepthook/threading.excepthook, by which point these are always
|
|
@@ -38,31 +61,105 @@ class _State(TypedDict):
|
|
|
38
61
|
_state: _State = {
|
|
39
62
|
"bot_token": None,
|
|
40
63
|
"chat_id": None,
|
|
64
|
+
"slack_webhook_url": None,
|
|
41
65
|
"redact": True,
|
|
42
66
|
"rate_limit_seconds": _DEFAULT_RATE_LIMIT_SECONDS,
|
|
43
67
|
"tags": {},
|
|
68
|
+
"blame": False,
|
|
44
69
|
"prev_excepthook": sys.excepthook,
|
|
45
70
|
"prev_threading_excepthook": threading.excepthook,
|
|
46
71
|
}
|
|
47
72
|
|
|
48
73
|
|
|
74
|
+
def _configured() -> bool:
|
|
75
|
+
return bool(
|
|
76
|
+
(_state["bot_token"] and _state["chat_id"]) or _state["slack_webhook_url"]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _deliver(format_telegram, format_slack) -> None:
|
|
81
|
+
"""Formats and sends to every channel init() configured -- each channel gets its
|
|
82
|
+
own formatting call since Telegram (HTML) and Slack (mrkdwn) need different
|
|
83
|
+
markup, but both share the same fingerprint/dedup decision made by the caller."""
|
|
84
|
+
if _state["bot_token"] and _state["chat_id"]:
|
|
85
|
+
message = format_telegram()
|
|
86
|
+
if _state["redact"]:
|
|
87
|
+
message = _redact(message)
|
|
88
|
+
_send_telegram_message(_state["bot_token"], _state["chat_id"], message)
|
|
89
|
+
if _state["slack_webhook_url"]:
|
|
90
|
+
message = format_slack()
|
|
91
|
+
if _state["redact"]:
|
|
92
|
+
message = _redact(message)
|
|
93
|
+
_send_slack_message(_state["slack_webhook_url"], message)
|
|
94
|
+
|
|
95
|
+
|
|
49
96
|
def _send_exception(
|
|
50
97
|
exc_type, exc_value, tb, extra: dict[str, str] | None = None
|
|
51
98
|
) -> None:
|
|
52
|
-
if
|
|
99
|
+
if not _configured():
|
|
53
100
|
print("devalerts: init() was not called, dropping alert", file=sys.stderr)
|
|
54
101
|
return
|
|
55
102
|
fingerprint, location = _fingerprint(exc_type, tb)
|
|
56
|
-
send, skipped = _should_send(
|
|
103
|
+
send, skipped, is_new = _should_send(
|
|
57
104
|
fingerprint, exc_type.__name__, location, _state["rate_limit_seconds"]
|
|
58
105
|
)
|
|
59
106
|
if not send:
|
|
60
107
|
return
|
|
61
108
|
tags = {**_state["tags"], **(extra or {})}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
109
|
+
blame = _git_blame_for_traceback(tb) if _state["blame"] else None
|
|
110
|
+
_deliver(
|
|
111
|
+
lambda: _format_alert(
|
|
112
|
+
exc_type,
|
|
113
|
+
exc_value,
|
|
114
|
+
tb,
|
|
115
|
+
skipped=skipped,
|
|
116
|
+
tags=tags,
|
|
117
|
+
blame=blame,
|
|
118
|
+
is_new=is_new,
|
|
119
|
+
),
|
|
120
|
+
lambda: _format_alert_slack(
|
|
121
|
+
exc_type,
|
|
122
|
+
exc_value,
|
|
123
|
+
tb,
|
|
124
|
+
skipped=skipped,
|
|
125
|
+
tags=tags,
|
|
126
|
+
blame=blame,
|
|
127
|
+
is_new=is_new,
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _send_log(record: logging.LogRecord, extra: dict[str, str] | None = None) -> None:
|
|
133
|
+
if not _configured():
|
|
134
|
+
print("devalerts: init() was not called, dropping alert", file=sys.stderr)
|
|
135
|
+
return
|
|
136
|
+
fingerprint, location = _fingerprint_log(
|
|
137
|
+
record.name, record.levelno, str(record.msg), record.pathname, record.lineno
|
|
138
|
+
)
|
|
139
|
+
send, skipped, is_new = _should_send(
|
|
140
|
+
fingerprint, record.name, location, _state["rate_limit_seconds"]
|
|
141
|
+
)
|
|
142
|
+
if not send:
|
|
143
|
+
return
|
|
144
|
+
tags = {**_state["tags"], **(extra or {})}
|
|
145
|
+
_deliver(
|
|
146
|
+
lambda: _format_log_alert(
|
|
147
|
+
record.name,
|
|
148
|
+
record.levelname,
|
|
149
|
+
record.getMessage(),
|
|
150
|
+
skipped=skipped,
|
|
151
|
+
tags=tags,
|
|
152
|
+
is_new=is_new,
|
|
153
|
+
),
|
|
154
|
+
lambda: _format_log_alert_slack(
|
|
155
|
+
record.name,
|
|
156
|
+
record.levelname,
|
|
157
|
+
record.getMessage(),
|
|
158
|
+
skipped=skipped,
|
|
159
|
+
tags=tags,
|
|
160
|
+
is_new=is_new,
|
|
161
|
+
),
|
|
162
|
+
)
|
|
66
163
|
|
|
67
164
|
|
|
68
165
|
def _excepthook(exc_type, exc_value, tb) -> None:
|
|
@@ -88,19 +185,33 @@ def _threading_excepthook(args) -> None:
|
|
|
88
185
|
|
|
89
186
|
|
|
90
187
|
def init(
|
|
91
|
-
bot_token: str,
|
|
92
|
-
chat_id: int | str,
|
|
188
|
+
bot_token: str | None = None,
|
|
189
|
+
chat_id: int | str | None = None,
|
|
93
190
|
*,
|
|
191
|
+
slack_webhook_url: str | None = None,
|
|
94
192
|
redact: bool = True,
|
|
95
193
|
rate_limit_seconds: int = _DEFAULT_RATE_LIMIT_SECONDS,
|
|
96
194
|
tags: dict[str, str] | None = None,
|
|
195
|
+
blame: bool = False,
|
|
97
196
|
) -> None:
|
|
98
|
-
"""Install a global exception hook that sends unhandled exceptions to Telegram
|
|
197
|
+
"""Install a global exception hook that sends unhandled exceptions to Telegram
|
|
198
|
+
and/or Slack. Requires ``bot_token``+``chat_id``, ``slack_webhook_url``, or both
|
|
199
|
+
-- configured channels all receive every alert.
|
|
200
|
+
|
|
201
|
+
``blame=True`` runs ``git blame`` on the line that raised and adds the
|
|
202
|
+
author/commit/date to the alert -- best-effort, silently skipped if
|
|
203
|
+
there's no git repo (e.g. a container image without ``.git``)."""
|
|
204
|
+
if bool(bot_token) != bool(chat_id):
|
|
205
|
+
raise ValueError("bot_token and chat_id must be given together")
|
|
206
|
+
if not bot_token and not slack_webhook_url:
|
|
207
|
+
raise ValueError("init() requires bot_token+chat_id and/or slack_webhook_url")
|
|
99
208
|
_state["bot_token"] = bot_token
|
|
100
209
|
_state["chat_id"] = chat_id
|
|
210
|
+
_state["slack_webhook_url"] = slack_webhook_url
|
|
101
211
|
_state["redact"] = redact
|
|
102
212
|
_state["rate_limit_seconds"] = rate_limit_seconds
|
|
103
213
|
_state["tags"] = tags or {}
|
|
214
|
+
_state["blame"] = blame
|
|
104
215
|
# ponytail: guard against calling init() twice capturing our own hook as
|
|
105
216
|
# "previous" -- that would make _excepthook chain to itself and recurse
|
|
106
217
|
# forever on the next crash, violating "must never raise".
|
|
@@ -185,3 +296,39 @@ class ASGIMiddleware:
|
|
|
185
296
|
target=_send_exception, args=(exc_type, exc_value, tb), daemon=True
|
|
186
297
|
).start()
|
|
187
298
|
raise
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class LogHandler(logging.Handler):
|
|
302
|
+
"""logging.Handler: report ERROR+ log records to every channel init()
|
|
303
|
+
configured -- catches exceptions
|
|
304
|
+
that are logged and swallowed (``logger.exception(...)``) rather than left to
|
|
305
|
+
propagate to ``sys.excepthook``, which never sees them.
|
|
306
|
+
|
|
307
|
+
Usage::
|
|
308
|
+
|
|
309
|
+
logging.getLogger().addHandler(devalerts.LogHandler())
|
|
310
|
+
|
|
311
|
+
A record with ``exc_info`` (``logger.exception()``, or
|
|
312
|
+
``logger.error(..., exc_info=True)``) is reported the same way an unhandled
|
|
313
|
+
instance of that exception would be -- same fingerprint, so logging it and then
|
|
314
|
+
re-raising sends one alert, not two. A plain ``logger.error("message")`` with no
|
|
315
|
+
exception is reported as a short text alert, grouped by logger name + level +
|
|
316
|
+
message.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
def __init__(
|
|
320
|
+
self, level: int = logging.ERROR, *, extra: dict[str, str] | None = None
|
|
321
|
+
) -> None:
|
|
322
|
+
super().__init__(level=level)
|
|
323
|
+
self._extra = extra
|
|
324
|
+
|
|
325
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
326
|
+
try:
|
|
327
|
+
tags = {"logger": record.name, **(self._extra or {})}
|
|
328
|
+
if record.exc_info and record.exc_info[0] is not None:
|
|
329
|
+
exc_type, exc_value, tb = record.exc_info
|
|
330
|
+
_send_exception(exc_type, exc_value, tb, extra=tags)
|
|
331
|
+
else:
|
|
332
|
+
_send_log(record, extra=tags)
|
|
333
|
+
except Exception: # noqa: BLE001 - a logging handler must never raise
|
|
334
|
+
self.handleError(record)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Alert message formatting and secret redaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import socket
|
|
7
|
+
import traceback
|
|
8
|
+
|
|
9
|
+
_MAX_MESSAGE_LENGTH = 4096
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _escape_html(text: str) -> str:
|
|
13
|
+
"""Escapes &, <, > -- required by both Telegram's HTML parse mode and Slack's
|
|
14
|
+
mrkdwn, which use the same three characters for their own markup."""
|
|
15
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _format_context(tags: dict[str, str] | None) -> str:
|
|
19
|
+
try:
|
|
20
|
+
hostname = socket.gethostname()
|
|
21
|
+
except OSError:
|
|
22
|
+
hostname = "unknown-host"
|
|
23
|
+
line = f"🖥️ {hostname}"
|
|
24
|
+
if tags:
|
|
25
|
+
line += " (" + ", ".join(f"{key}={value}" for key, value in tags.items()) + ")"
|
|
26
|
+
return line
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _format_alert(
|
|
30
|
+
exc_type,
|
|
31
|
+
exc_value,
|
|
32
|
+
tb,
|
|
33
|
+
skipped: int = 0,
|
|
34
|
+
tags: dict[str, str] | None = None,
|
|
35
|
+
blame: str | None = None,
|
|
36
|
+
is_new: bool = False,
|
|
37
|
+
) -> str:
|
|
38
|
+
header = f"\U0001f534 {exc_type.__name__}: {exc_value}\n{_format_context(tags)}"
|
|
39
|
+
if is_new:
|
|
40
|
+
header = f"🆕 New error\n{header}"
|
|
41
|
+
if blame:
|
|
42
|
+
header += f"\n🕵️ blame: {blame}"
|
|
43
|
+
if skipped:
|
|
44
|
+
header += f"\n⚠️ Повторилась ещё {skipped} раз(а) с последнего алерта"
|
|
45
|
+
body = "".join(traceback.format_exception(exc_type, exc_value, tb))
|
|
46
|
+
|
|
47
|
+
if len(header) + 2 + len(body) > _MAX_MESSAGE_LENGTH:
|
|
48
|
+
marker = "\n\n...(truncated)...\n"
|
|
49
|
+
# ponytail: header itself can exceed the limit if exc_value's str() is huge
|
|
50
|
+
# (e.g. a validation error echoing a large payload) — keep can go negative,
|
|
51
|
+
# so clamp it; the header itself gets hard-truncated as a backstop. The "2"
|
|
52
|
+
# accounts for the "\n\n" separator joined in below.
|
|
53
|
+
keep = max(_MAX_MESSAGE_LENGTH - len(header) - 2 - len(marker), 0)
|
|
54
|
+
body = f"{marker}{body[-keep:]}" if keep else ""
|
|
55
|
+
header = header[:_MAX_MESSAGE_LENGTH]
|
|
56
|
+
|
|
57
|
+
# Budgeted above against the plain text -- Telegram's 4096-char limit
|
|
58
|
+
# applies to the parsed (tag-stripped) text, not the raw HTML we send.
|
|
59
|
+
escaped_header = _escape_html(header)
|
|
60
|
+
if not body:
|
|
61
|
+
return escaped_header
|
|
62
|
+
return (
|
|
63
|
+
f"{escaped_header}\n\n<blockquote expandable>{_escape_html(body)}</blockquote>"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _format_alert_slack(
|
|
68
|
+
exc_type,
|
|
69
|
+
exc_value,
|
|
70
|
+
tb,
|
|
71
|
+
skipped: int = 0,
|
|
72
|
+
tags: dict[str, str] | None = None,
|
|
73
|
+
blame: str | None = None,
|
|
74
|
+
is_new: bool = False,
|
|
75
|
+
) -> str:
|
|
76
|
+
header = f"*\U0001f534 {exc_type.__name__}: {exc_value}*\n{_format_context(tags)}"
|
|
77
|
+
if is_new:
|
|
78
|
+
header = f"🆕 New error\n{header}"
|
|
79
|
+
if blame:
|
|
80
|
+
header += f"\n🕵️ blame: {blame}"
|
|
81
|
+
if skipped:
|
|
82
|
+
header += f"\n⚠️ Повторилась ещё {skipped} раз(а) с последнего алерта"
|
|
83
|
+
body = "".join(traceback.format_exception(exc_type, exc_value, tb))
|
|
84
|
+
|
|
85
|
+
fence = "```"
|
|
86
|
+
if len(header) + 2 + len(fence) * 2 + len(body) > _MAX_MESSAGE_LENGTH:
|
|
87
|
+
marker = "\n\n...(truncated)...\n"
|
|
88
|
+
keep = max(
|
|
89
|
+
_MAX_MESSAGE_LENGTH - len(header) - 2 - len(fence) * 2 - len(marker), 0
|
|
90
|
+
)
|
|
91
|
+
body = f"{marker}{body[-keep:]}" if keep else ""
|
|
92
|
+
header = header[:_MAX_MESSAGE_LENGTH]
|
|
93
|
+
|
|
94
|
+
escaped_header = _escape_html(header)
|
|
95
|
+
if not body:
|
|
96
|
+
return escaped_header
|
|
97
|
+
return f"{escaped_header}\n\n{fence}{_escape_html(body)}{fence}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _format_log_alert_slack(
|
|
101
|
+
logger_name: str,
|
|
102
|
+
level_name: str,
|
|
103
|
+
message: str,
|
|
104
|
+
skipped: int = 0,
|
|
105
|
+
tags: dict[str, str] | None = None,
|
|
106
|
+
is_new: bool = False,
|
|
107
|
+
) -> str:
|
|
108
|
+
header = (
|
|
109
|
+
f"*\U0001f534 {logger_name} ({level_name}): {message}*\n{_format_context(tags)}"
|
|
110
|
+
)
|
|
111
|
+
if is_new:
|
|
112
|
+
header = f"🆕 New error\n{header}"
|
|
113
|
+
if skipped:
|
|
114
|
+
header += f"\n⚠️ Повторилась ещё {skipped} раз(а) с последнего алерта"
|
|
115
|
+
return _escape_html(header[:_MAX_MESSAGE_LENGTH])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _format_log_alert(
|
|
119
|
+
logger_name: str,
|
|
120
|
+
level_name: str,
|
|
121
|
+
message: str,
|
|
122
|
+
skipped: int = 0,
|
|
123
|
+
tags: dict[str, str] | None = None,
|
|
124
|
+
is_new: bool = False,
|
|
125
|
+
) -> str:
|
|
126
|
+
header = (
|
|
127
|
+
f"\U0001f534 {logger_name} ({level_name}): {message}\n{_format_context(tags)}"
|
|
128
|
+
)
|
|
129
|
+
if is_new:
|
|
130
|
+
header = f"🆕 New error\n{header}"
|
|
131
|
+
if skipped:
|
|
132
|
+
header += f"\n⚠️ Повторилась ещё {skipped} раз(а) с последнего алерта"
|
|
133
|
+
return _escape_html(header[:_MAX_MESSAGE_LENGTH])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ponytail: fixed pattern list, not exhaustive — catches common
|
|
137
|
+
# token/key shapes only. Upgrade to entropy-based detection if
|
|
138
|
+
# real users report leaked secrets slipping through.
|
|
139
|
+
_REDACT_PATTERNS = [
|
|
140
|
+
(re.compile(r"AKIA[0-9A-Z]{16}"), "[REDACTED]"),
|
|
141
|
+
(re.compile(r"Bearer\s+[A-Za-z0-9\-_.]+", re.IGNORECASE), "Bearer [REDACTED]"),
|
|
142
|
+
(
|
|
143
|
+
re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[=:]\s*\S+"),
|
|
144
|
+
r"\1=[REDACTED]",
|
|
145
|
+
),
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _redact(text: str) -> str:
|
|
150
|
+
for pattern, replacement in _REDACT_PATTERNS:
|
|
151
|
+
text = pattern.sub(replacement, text)
|
|
152
|
+
return text
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Best-effort ``git blame`` lookup for the line that raised. Never raises --
|
|
2
|
+
returns None on any failure (no git installed, not a repo, uncommitted line,
|
|
3
|
+
timeout, ...)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
import traceback
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
|
|
13
|
+
_TIMEOUT_SECONDS = 2
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _relative_time(seconds_ago: float) -> str:
|
|
17
|
+
seconds_ago = max(seconds_ago, 0)
|
|
18
|
+
minutes = seconds_ago / 60
|
|
19
|
+
if minutes < 1:
|
|
20
|
+
return "just now"
|
|
21
|
+
if minutes < 60:
|
|
22
|
+
return f"{int(minutes)}m ago"
|
|
23
|
+
hours = minutes / 60
|
|
24
|
+
if hours < 24:
|
|
25
|
+
return f"{int(hours)}h ago"
|
|
26
|
+
days = hours / 24
|
|
27
|
+
if days < 30:
|
|
28
|
+
return f"{int(days)}d ago"
|
|
29
|
+
months = days / 30
|
|
30
|
+
if months < 12:
|
|
31
|
+
return f"{int(months)}mo ago"
|
|
32
|
+
return f"{int(months / 12)}y ago"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _git_blame(filename: str, lineno: int) -> str | None:
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run(
|
|
38
|
+
[
|
|
39
|
+
"git",
|
|
40
|
+
"-C",
|
|
41
|
+
str(Path(filename).parent),
|
|
42
|
+
"blame",
|
|
43
|
+
"-L",
|
|
44
|
+
f"{lineno},{lineno}",
|
|
45
|
+
"--porcelain",
|
|
46
|
+
"--",
|
|
47
|
+
filename,
|
|
48
|
+
],
|
|
49
|
+
capture_output=True,
|
|
50
|
+
text=True,
|
|
51
|
+
timeout=_TIMEOUT_SECONDS,
|
|
52
|
+
)
|
|
53
|
+
if result.returncode != 0 or not result.stdout:
|
|
54
|
+
return None
|
|
55
|
+
lines = result.stdout.splitlines()
|
|
56
|
+
sha = lines[0].split()[0]
|
|
57
|
+
author: str | None = None
|
|
58
|
+
author_time: int | None = None
|
|
59
|
+
for line in lines[1:]:
|
|
60
|
+
if line.startswith("author "):
|
|
61
|
+
author = line[len("author ") :]
|
|
62
|
+
elif line.startswith("author-time "):
|
|
63
|
+
author_time = int(line[len("author-time ") :])
|
|
64
|
+
elif line.startswith("\t"):
|
|
65
|
+
break
|
|
66
|
+
if not author or author_time is None or author == "Not Committed Yet":
|
|
67
|
+
return None
|
|
68
|
+
date = time.strftime("%Y-%m-%d", time.localtime(author_time))
|
|
69
|
+
age = _relative_time(time.time() - author_time)
|
|
70
|
+
return f"{author} · {sha[:7]} · {date} ({age})"
|
|
71
|
+
except Exception: # noqa: BLE001 - best-effort, must never raise
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _git_blame_for_traceback(tb: TracebackType | None) -> str | None:
|
|
76
|
+
frames = traceback.extract_tb(tb)
|
|
77
|
+
if not frames:
|
|
78
|
+
return None
|
|
79
|
+
lineno = frames[-1].lineno
|
|
80
|
+
if lineno is None:
|
|
81
|
+
return None
|
|
82
|
+
return _git_blame(frames[-1].filename, lineno)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Slack incoming-webhook delivery -- retries transient failures, logs to the
|
|
2
|
+
same local fallback file Telegram delivery uses if every attempt fails. Never
|
|
3
|
+
raises."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
from ._telegram import _log_failed_delivery
|
|
14
|
+
|
|
15
|
+
_TIMEOUT_SECONDS = 5
|
|
16
|
+
_MAX_ATTEMPTS = 2
|
|
17
|
+
_RETRY_BACKOFF_SECONDS = 1.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _send_slack_message(webhook_url: str, text: str) -> bool:
|
|
21
|
+
payload = json.dumps({"text": text}).encode("utf-8")
|
|
22
|
+
last_error: Exception | None = None
|
|
23
|
+
|
|
24
|
+
for attempt in range(_MAX_ATTEMPTS):
|
|
25
|
+
request = urllib.request.Request(
|
|
26
|
+
webhook_url,
|
|
27
|
+
data=payload,
|
|
28
|
+
headers={"Content-Type": "application/json"},
|
|
29
|
+
method="POST",
|
|
30
|
+
)
|
|
31
|
+
try:
|
|
32
|
+
urllib.request.urlopen(request, timeout=_TIMEOUT_SECONDS)
|
|
33
|
+
return True
|
|
34
|
+
except (urllib.error.URLError, OSError, ValueError) as error:
|
|
35
|
+
last_error = error
|
|
36
|
+
|
|
37
|
+
if attempt < _MAX_ATTEMPTS - 1:
|
|
38
|
+
time.sleep(_RETRY_BACKOFF_SECONDS)
|
|
39
|
+
|
|
40
|
+
print(f"devalerts: failed to send Slack alert: {last_error}", file=sys.stderr)
|
|
41
|
+
_log_failed_delivery(text)
|
|
42
|
+
return False
|
|
@@ -24,6 +24,14 @@ def _fingerprint(exc_type, tb) -> tuple[str, str]:
|
|
|
24
24
|
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16], location
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def _fingerprint_log(
|
|
28
|
+
logger_name: str, level: int, msg: str, pathname: str, lineno: int
|
|
29
|
+
) -> tuple[str, str]:
|
|
30
|
+
location = f"{pathname}:{lineno}"
|
|
31
|
+
raw = f"{logger_name}:{level}:{msg}:{location}"
|
|
32
|
+
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16], location
|
|
33
|
+
|
|
34
|
+
|
|
27
35
|
def _get_connection() -> sqlite3.Connection:
|
|
28
36
|
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
29
37
|
conn = sqlite3.connect(_DB_PATH, timeout=5)
|
|
@@ -62,7 +70,7 @@ def _get_connection() -> sqlite3.Connection:
|
|
|
62
70
|
|
|
63
71
|
def _should_send(
|
|
64
72
|
fingerprint: str, exc_type_name: str, location: str, rate_limit_seconds: int
|
|
65
|
-
) -> tuple[bool, int]:
|
|
73
|
+
) -> tuple[bool, int, bool]:
|
|
66
74
|
now = time.time()
|
|
67
75
|
try:
|
|
68
76
|
conn = _get_connection()
|
|
@@ -73,6 +81,7 @@ def _should_send(
|
|
|
73
81
|
"FROM error_groups WHERE fingerprint = ?",
|
|
74
82
|
(fingerprint,),
|
|
75
83
|
).fetchone()
|
|
84
|
+
is_new = row is None
|
|
76
85
|
muted = bool(row[2]) if row else False
|
|
77
86
|
multiplier = row[3] if row else 1
|
|
78
87
|
effective_window = rate_limit_seconds * multiplier
|
|
@@ -130,7 +139,7 @@ def _should_send(
|
|
|
130
139
|
"DELETE FROM error_groups WHERE last_seen < ?",
|
|
131
140
|
(now - _RETENTION_SECONDS,),
|
|
132
141
|
)
|
|
133
|
-
return send, skipped
|
|
142
|
+
return send, skipped, is_new
|
|
134
143
|
finally:
|
|
135
144
|
conn.close()
|
|
136
145
|
except (sqlite3.Error, OSError) as error:
|
|
@@ -140,7 +149,7 @@ def _should_send(
|
|
|
140
149
|
f"devalerts: dedup/rate-limit state error, sending anyway: {error}",
|
|
141
150
|
file=sys.stderr,
|
|
142
151
|
)
|
|
143
|
-
return True, 0
|
|
152
|
+
return True, 0, True
|
|
144
153
|
|
|
145
154
|
|
|
146
155
|
def _match_fingerprints(prefix: str) -> list[str]:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""CLI: `devalerts dashboard` reports grouped/rate-limited errors from the local
|
|
2
|
-
state DB; `devalerts test` sends a one-off message to verify bot_token/chat_id
|
|
2
|
+
state DB; `devalerts test` sends a one-off message to verify bot_token/chat_id
|
|
3
|
+
and/or slack_webhook_url."""
|
|
3
4
|
|
|
4
5
|
from __future__ import annotations
|
|
5
6
|
|
|
@@ -11,6 +12,7 @@ import sys
|
|
|
11
12
|
import time
|
|
12
13
|
from importlib.metadata import version as _pkg_version
|
|
13
14
|
|
|
15
|
+
from ._slack import _send_slack_message
|
|
14
16
|
from ._store import (
|
|
15
17
|
_DB_PATH,
|
|
16
18
|
_DEFAULT_RATE_LIMIT_SECONDS,
|
|
@@ -27,7 +29,7 @@ _LOCATION_WIDTH = 34
|
|
|
27
29
|
def _supports_unicode() -> bool:
|
|
28
30
|
encoding = getattr(sys.stdout, "encoding", None) or ""
|
|
29
31
|
try:
|
|
30
|
-
"
|
|
32
|
+
"─●…×".encode(encoding)
|
|
31
33
|
return True
|
|
32
34
|
except (LookupError, UnicodeEncodeError, TypeError):
|
|
33
35
|
return False
|
|
@@ -153,6 +155,7 @@ def _dashboard(as_json: bool = False) -> int:
|
|
|
153
155
|
sep_char = "─" if unicode_ok else "-"
|
|
154
156
|
dot_char = "●" if unicode_ok else "*"
|
|
155
157
|
ellipsis = "…" if unicode_ok else "..."
|
|
158
|
+
times_char = "×" if unicode_ok else "x"
|
|
156
159
|
|
|
157
160
|
style = _Style(_color_enabled())
|
|
158
161
|
type_width = max(len("TYPE"), *(len(r[1]) for r in rows))
|
|
@@ -178,7 +181,9 @@ def _dashboard(as_json: bool = False) -> int:
|
|
|
178
181
|
muted,
|
|
179
182
|
backoff_multiplier,
|
|
180
183
|
) in rows:
|
|
181
|
-
backoff_suffix =
|
|
184
|
+
backoff_suffix = (
|
|
185
|
+
f" {times_char}{backoff_multiplier}" if backoff_multiplier > 1 else ""
|
|
186
|
+
)
|
|
182
187
|
if muted:
|
|
183
188
|
muted_count += 1
|
|
184
189
|
status = style.dim(f"{dot_char} muted")
|
|
@@ -250,16 +255,41 @@ def _clear_command(prefix: str | None, clear_all: bool) -> int:
|
|
|
250
255
|
return 0
|
|
251
256
|
|
|
252
257
|
|
|
253
|
-
def _test(
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
"
|
|
258
|
-
|
|
258
|
+
def _test(
|
|
259
|
+
bot_token: str | None, chat_id: str | None, slack_webhook_url: str | None
|
|
260
|
+
) -> int:
|
|
261
|
+
if bool(bot_token) != bool(chat_id):
|
|
262
|
+
print("--bot-token and --chat-id must be given together.", file=sys.stderr)
|
|
263
|
+
return 1
|
|
264
|
+
if not bot_token and not slack_webhook_url:
|
|
265
|
+
print(
|
|
266
|
+
"Provide --bot-token/--chat-id and/or --slack-webhook-url.",
|
|
267
|
+
file=sys.stderr,
|
|
268
|
+
)
|
|
269
|
+
return 1
|
|
270
|
+
ok = True
|
|
271
|
+
if bot_token and chat_id:
|
|
272
|
+
ok = (
|
|
273
|
+
_send_telegram_message(
|
|
274
|
+
bot_token,
|
|
275
|
+
chat_id,
|
|
276
|
+
"✅ devalerts test message -- bot_token and chat_id are wired up "
|
|
277
|
+
"correctly.",
|
|
278
|
+
)
|
|
279
|
+
and ok
|
|
280
|
+
)
|
|
281
|
+
if slack_webhook_url:
|
|
282
|
+
ok = (
|
|
283
|
+
_send_slack_message(
|
|
284
|
+
slack_webhook_url,
|
|
285
|
+
"✅ devalerts test message -- slack_webhook_url is wired up correctly.",
|
|
286
|
+
)
|
|
287
|
+
and ok
|
|
288
|
+
)
|
|
259
289
|
if not ok:
|
|
260
290
|
print("Failed to send test message (see error above).", file=sys.stderr)
|
|
261
291
|
return 1
|
|
262
|
-
print("Test message sent -- check your
|
|
292
|
+
print("Test message sent -- check your chat.")
|
|
263
293
|
return 0
|
|
264
294
|
|
|
265
295
|
|
|
@@ -276,10 +306,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
276
306
|
"--json", action="store_true", help="Output as JSON instead of a table"
|
|
277
307
|
)
|
|
278
308
|
test_parser = subparsers.add_parser(
|
|
279
|
-
"test",
|
|
309
|
+
"test",
|
|
310
|
+
help="Send a test message to verify bot_token/chat_id and/or slack_webhook_url",
|
|
280
311
|
)
|
|
281
|
-
test_parser.add_argument("--bot-token"
|
|
282
|
-
test_parser.add_argument("--chat-id"
|
|
312
|
+
test_parser.add_argument("--bot-token")
|
|
313
|
+
test_parser.add_argument("--chat-id")
|
|
314
|
+
test_parser.add_argument("--slack-webhook-url")
|
|
283
315
|
mute_parser = subparsers.add_parser("mute", help="Silence a specific error group")
|
|
284
316
|
mute_parser.add_argument(
|
|
285
317
|
"fingerprint", help="Fingerprint or unique prefix (dashboard ID column)"
|
|
@@ -303,7 +335,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
303
335
|
if args.command == "dashboard":
|
|
304
336
|
return _dashboard(as_json=args.json)
|
|
305
337
|
if args.command == "test":
|
|
306
|
-
return _test(args.bot_token, args.chat_id)
|
|
338
|
+
return _test(args.bot_token, args.chat_id, args.slack_webhook_url)
|
|
307
339
|
if args.command == "mute":
|
|
308
340
|
return _mute_command(args.fingerprint, muted=True)
|
|
309
341
|
if args.command == "unmute":
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
"""Alert message formatting and secret redaction."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import re
|
|
6
|
-
import socket
|
|
7
|
-
import traceback
|
|
8
|
-
|
|
9
|
-
_MAX_MESSAGE_LENGTH = 4096
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def _escape_html(text: str) -> str:
|
|
13
|
-
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def _format_context(tags: dict[str, str] | None) -> str:
|
|
17
|
-
try:
|
|
18
|
-
hostname = socket.gethostname()
|
|
19
|
-
except OSError:
|
|
20
|
-
hostname = "unknown-host"
|
|
21
|
-
line = f"🖥️ {hostname}"
|
|
22
|
-
if tags:
|
|
23
|
-
line += " (" + ", ".join(f"{key}={value}" for key, value in tags.items()) + ")"
|
|
24
|
-
return line
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def _format_alert(
|
|
28
|
-
exc_type, exc_value, tb, skipped: int = 0, tags: dict[str, str] | None = None
|
|
29
|
-
) -> str:
|
|
30
|
-
header = f"\U0001f534 {exc_type.__name__}: {exc_value}\n{_format_context(tags)}"
|
|
31
|
-
if skipped:
|
|
32
|
-
header += f"\n⚠️ Повторилась ещё {skipped} раз(а) с последнего алерта"
|
|
33
|
-
body = "".join(traceback.format_exception(exc_type, exc_value, tb))
|
|
34
|
-
|
|
35
|
-
if len(header) + 2 + len(body) > _MAX_MESSAGE_LENGTH:
|
|
36
|
-
marker = "\n\n...(truncated)...\n"
|
|
37
|
-
# ponytail: header itself can exceed the limit if exc_value's str() is huge
|
|
38
|
-
# (e.g. a validation error echoing a large payload) — keep can go negative,
|
|
39
|
-
# so clamp it; the header itself gets hard-truncated as a backstop. The "2"
|
|
40
|
-
# accounts for the "\n\n" separator joined in below.
|
|
41
|
-
keep = max(_MAX_MESSAGE_LENGTH - len(header) - 2 - len(marker), 0)
|
|
42
|
-
body = f"{marker}{body[-keep:]}" if keep else ""
|
|
43
|
-
header = header[:_MAX_MESSAGE_LENGTH]
|
|
44
|
-
|
|
45
|
-
# Budgeted above against the plain text -- Telegram's 4096-char limit
|
|
46
|
-
# applies to the parsed (tag-stripped) text, not the raw HTML we send.
|
|
47
|
-
escaped_header = _escape_html(header)
|
|
48
|
-
if not body:
|
|
49
|
-
return escaped_header
|
|
50
|
-
return (
|
|
51
|
-
f"{escaped_header}\n\n<blockquote expandable>{_escape_html(body)}</blockquote>"
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
# ponytail: fixed pattern list, not exhaustive — catches common
|
|
56
|
-
# token/key shapes only. Upgrade to entropy-based detection if
|
|
57
|
-
# real users report leaked secrets slipping through.
|
|
58
|
-
_REDACT_PATTERNS = [
|
|
59
|
-
(re.compile(r"AKIA[0-9A-Z]{16}"), "[REDACTED]"),
|
|
60
|
-
(re.compile(r"Bearer\s+[A-Za-z0-9\-_.]+", re.IGNORECASE), "Bearer [REDACTED]"),
|
|
61
|
-
(
|
|
62
|
-
re.compile(r"(?i)(api[_-]?key|secret|token|password)\s*[=:]\s*\S+"),
|
|
63
|
-
r"\1=[REDACTED]",
|
|
64
|
-
),
|
|
65
|
-
]
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def _redact(text: str) -> str:
|
|
69
|
-
for pattern, replacement in _REDACT_PATTERNS:
|
|
70
|
-
text = pattern.sub(replacement, text)
|
|
71
|
-
return text
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|