devalerts 0.2.3__tar.gz → 0.4.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.3 → devalerts-0.4.0}/PKG-INFO +99 -13
- {devalerts-0.2.3 → devalerts-0.4.0}/README.md +97 -11
- {devalerts-0.2.3 → devalerts-0.4.0}/pyproject.toml +2 -2
- {devalerts-0.2.3 → devalerts-0.4.0}/src/devalerts/__init__.py +160 -13
- devalerts-0.4.0/src/devalerts/_alert.py +152 -0
- devalerts-0.4.0/src/devalerts/_badge.py +55 -0
- devalerts-0.4.0/src/devalerts/_blame.py +82 -0
- devalerts-0.4.0/src/devalerts/_slack.py +42 -0
- {devalerts-0.2.3 → devalerts-0.4.0}/src/devalerts/_store.py +24 -3
- {devalerts-0.2.3 → devalerts-0.4.0}/src/devalerts/cli.py +90 -12
- devalerts-0.2.3/src/devalerts/_alert.py +0 -71
- {devalerts-0.2.3 → devalerts-0.4.0}/LICENSE +0 -0
- {devalerts-0.2.3 → devalerts-0.4.0}/src/devalerts/_celery.py +0 -0
- {devalerts-0.2.3 → devalerts-0.4.0}/src/devalerts/_telegram.py +0 -0
- {devalerts-0.2.3 → devalerts-0.4.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.4.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
|
|
@@ -139,6 +139,34 @@ resend doubles the effective `rate_limit_seconds` for that group (capped at
|
|
|
139
139
|
forever. A group that goes quiet and reappears once resets to the base rate
|
|
140
140
|
immediately. The dashboard shows the active multiplier (`● sending ×4`).
|
|
141
141
|
|
|
142
|
+
## Crash streak badge
|
|
143
|
+
|
|
144
|
+
`devalerts dashboard` also reports how long it's been since the last
|
|
145
|
+
recorded incident (sent or suppressed by rate-limiting) — same idea as the
|
|
146
|
+
classic "days since last workplace accident" sign, applied to your crashes:
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
🟢 14 days since the last incident.
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Get the same number as a shareable SVG badge for your README:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
uv run devalerts badge --out docs/streak.svg
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+

|
|
159
|
+
|
|
160
|
+
No hosting, no cron job built in — regenerate and commit it yourself,
|
|
161
|
+
e.g. on a schedule with GitHub Actions:
|
|
162
|
+
|
|
163
|
+
```yaml
|
|
164
|
+
- run: uv run devalerts badge --out docs/streak.svg
|
|
165
|
+
- run: git add docs/streak.svg && git commit -m "Update crash streak badge" || true
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Pass `--label` to customize the left-hand text (default `"crash streak"`).
|
|
169
|
+
|
|
142
170
|
## Context: hostname and tags
|
|
143
171
|
|
|
144
172
|
Every alert automatically includes the sending host, so you can tell which
|
|
@@ -232,6 +260,60 @@ grouping/rate-limiting/redaction path as everything else, tagged with the
|
|
|
232
260
|
task name and id automatically. Requires Celery to already be installed in
|
|
233
261
|
the worker process — it's imported lazily, not a devalerts dependency.
|
|
234
262
|
|
|
263
|
+
## Logged (not raised) exceptions
|
|
264
|
+
|
|
265
|
+
`init()`'s excepthook only ever sees exceptions that stay unhandled — but most
|
|
266
|
+
real errors are caught and logged instead: `logger.exception(...)` inside a
|
|
267
|
+
`try/except` never propagates anywhere. Attach `LogHandler` to catch those too:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
import logging
|
|
271
|
+
|
|
272
|
+
logging.getLogger().addHandler(devalerts.LogHandler())
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
A record with `exc_info` (`logger.exception()`, or `logger.error(...,
|
|
276
|
+
exc_info=True)`) is reported like a full exception alert — grouped by the
|
|
277
|
+
same fingerprint an unhandled instance of it would use, so logging it and
|
|
278
|
+
then re-raising sends one alert, not two. A plain `logger.error("message")`
|
|
279
|
+
with no exception attached is reported as a short text alert, grouped by
|
|
280
|
+
logger name + level + message. Defaults to `level=logging.ERROR`; pass
|
|
281
|
+
`extra={...}` for tags added to every alert from this handler.
|
|
282
|
+
|
|
283
|
+
## Blame
|
|
284
|
+
|
|
285
|
+
Curious *who* wrote the line that just blew up? `blame=True` runs `git blame`
|
|
286
|
+
on it and adds the author, short commit hash, and date to the alert:
|
|
287
|
+
|
|
288
|
+
```python
|
|
289
|
+
devalerts.init(bot_token="...", chat_id=123456789, blame=True)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
```
|
|
293
|
+
🕵️ blame: sslinNn · a1b2c3d · 2026-07-15 (3d ago)
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Best-effort and silent about it — no git installed, no repo (a container
|
|
297
|
+
image without `.git`), or the line isn't committed yet all just skip the
|
|
298
|
+
line instead of failing the alert. Off by default; opt in with `blame=True`.
|
|
299
|
+
|
|
300
|
+
## Slack
|
|
301
|
+
|
|
302
|
+
Prefer Slack, or want both? Pass a Slack incoming webhook URL alongside or
|
|
303
|
+
instead of `bot_token`/`chat_id` — every configured channel gets every alert:
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
devalerts.init(slack_webhook_url="https://hooks.slack.com/services/...")
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Create one at [api.slack.com/apps](https://api.slack.com/apps) → your app →
|
|
310
|
+
**Incoming Webhooks**. Same grouping/rate-limiting/redaction/blame path as
|
|
311
|
+
Telegram, formatted as Slack mrkdwn instead of HTML. Verify it's wired up:
|
|
312
|
+
|
|
313
|
+
```
|
|
314
|
+
uv run devalerts test --slack-webhook-url https://hooks.slack.com/services/...
|
|
315
|
+
```
|
|
316
|
+
|
|
235
317
|
## Why not Sentry?
|
|
236
318
|
|
|
237
319
|
If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
|
|
@@ -265,22 +347,27 @@ Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
|
|
|
265
347
|
Yes — call [`init_celery()`](#celery) in addition to `init()` to catch
|
|
266
348
|
exceptions raised inside tasks, which the excepthook alone won't see.
|
|
267
349
|
|
|
350
|
+
**Catches exceptions I log but don't re-raise?**
|
|
351
|
+
Yes — add [`LogHandler`](#logged-not-raised-exceptions) to a logger; the
|
|
352
|
+
excepthook alone never sees a caught-and-logged exception.
|
|
353
|
+
|
|
268
354
|
**Works on Windows / Linux / macOS?**
|
|
269
355
|
Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
|
|
270
356
|
paths.
|
|
271
357
|
|
|
272
358
|
## Privacy & Security
|
|
273
359
|
|
|
274
|
-
- The only network
|
|
275
|
-
telemetry, no analytics,
|
|
360
|
+
- The only network calls devalerts makes are to `api.telegram.org` and/or
|
|
361
|
+
your configured Slack incoming webhook — no telemetry, no analytics,
|
|
362
|
+
nothing else phones home.
|
|
276
363
|
- No third-party server and no devalerts-run backend — messages go straight
|
|
277
|
-
from your process to your own Telegram bot.
|
|
278
|
-
- No accounts, no signup, no API key beyond the bot token
|
|
279
|
-
control yourself.
|
|
364
|
+
from your process to your own Telegram bot / Slack webhook.
|
|
365
|
+
- No accounts, no signup, no API key beyond the bot token / webhook URL you
|
|
366
|
+
create and control yourself.
|
|
280
367
|
- Basic secret redaction only (a few common token/key patterns) — do not
|
|
281
368
|
rely on this for sensitive production data; scrub what you can before it
|
|
282
369
|
ever reaches an exception message.
|
|
283
|
-
- If
|
|
370
|
+
- If delivery fails after retrying, the alert (already redacted, if
|
|
284
371
|
`redact=True`) is appended to `~/.devalerts/failed.log` instead of being
|
|
285
372
|
dropped — clean it up like any other local log file.
|
|
286
373
|
|
|
@@ -293,7 +380,6 @@ paths.
|
|
|
293
380
|
## Roadmap
|
|
294
381
|
|
|
295
382
|
- Web dashboard (hosted, optional — the local CLI dashboard stays either way)
|
|
296
|
-
- Slack delivery
|
|
297
383
|
- Discord delivery
|
|
298
384
|
- Email delivery
|
|
299
385
|
|
|
@@ -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
|
|
@@ -112,6 +112,34 @@ resend doubles the effective `rate_limit_seconds` for that group (capped at
|
|
|
112
112
|
forever. A group that goes quiet and reappears once resets to the base rate
|
|
113
113
|
immediately. The dashboard shows the active multiplier (`● sending ×4`).
|
|
114
114
|
|
|
115
|
+
## Crash streak badge
|
|
116
|
+
|
|
117
|
+
`devalerts dashboard` also reports how long it's been since the last
|
|
118
|
+
recorded incident (sent or suppressed by rate-limiting) — same idea as the
|
|
119
|
+
classic "days since last workplace accident" sign, applied to your crashes:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
🟢 14 days since the last incident.
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Get the same number as a shareable SVG badge for your README:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
uv run devalerts badge --out docs/streak.svg
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+

|
|
132
|
+
|
|
133
|
+
No hosting, no cron job built in — regenerate and commit it yourself,
|
|
134
|
+
e.g. on a schedule with GitHub Actions:
|
|
135
|
+
|
|
136
|
+
```yaml
|
|
137
|
+
- run: uv run devalerts badge --out docs/streak.svg
|
|
138
|
+
- run: git add docs/streak.svg && git commit -m "Update crash streak badge" || true
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Pass `--label` to customize the left-hand text (default `"crash streak"`).
|
|
142
|
+
|
|
115
143
|
## Context: hostname and tags
|
|
116
144
|
|
|
117
145
|
Every alert automatically includes the sending host, so you can tell which
|
|
@@ -205,6 +233,60 @@ grouping/rate-limiting/redaction path as everything else, tagged with the
|
|
|
205
233
|
task name and id automatically. Requires Celery to already be installed in
|
|
206
234
|
the worker process — it's imported lazily, not a devalerts dependency.
|
|
207
235
|
|
|
236
|
+
## Logged (not raised) exceptions
|
|
237
|
+
|
|
238
|
+
`init()`'s excepthook only ever sees exceptions that stay unhandled — but most
|
|
239
|
+
real errors are caught and logged instead: `logger.exception(...)` inside a
|
|
240
|
+
`try/except` never propagates anywhere. Attach `LogHandler` to catch those too:
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
import logging
|
|
244
|
+
|
|
245
|
+
logging.getLogger().addHandler(devalerts.LogHandler())
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
A record with `exc_info` (`logger.exception()`, or `logger.error(...,
|
|
249
|
+
exc_info=True)`) is reported like a full exception alert — grouped by the
|
|
250
|
+
same fingerprint an unhandled instance of it would use, so logging it and
|
|
251
|
+
then re-raising sends one alert, not two. A plain `logger.error("message")`
|
|
252
|
+
with no exception attached is reported as a short text alert, grouped by
|
|
253
|
+
logger name + level + message. Defaults to `level=logging.ERROR`; pass
|
|
254
|
+
`extra={...}` for tags added to every alert from this handler.
|
|
255
|
+
|
|
256
|
+
## Blame
|
|
257
|
+
|
|
258
|
+
Curious *who* wrote the line that just blew up? `blame=True` runs `git blame`
|
|
259
|
+
on it and adds the author, short commit hash, and date to the alert:
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
devalerts.init(bot_token="...", chat_id=123456789, blame=True)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
```
|
|
266
|
+
🕵️ blame: sslinNn · a1b2c3d · 2026-07-15 (3d ago)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Best-effort and silent about it — no git installed, no repo (a container
|
|
270
|
+
image without `.git`), or the line isn't committed yet all just skip the
|
|
271
|
+
line instead of failing the alert. Off by default; opt in with `blame=True`.
|
|
272
|
+
|
|
273
|
+
## Slack
|
|
274
|
+
|
|
275
|
+
Prefer Slack, or want both? Pass a Slack incoming webhook URL alongside or
|
|
276
|
+
instead of `bot_token`/`chat_id` — every configured channel gets every alert:
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
devalerts.init(slack_webhook_url="https://hooks.slack.com/services/...")
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Create one at [api.slack.com/apps](https://api.slack.com/apps) → your app →
|
|
283
|
+
**Incoming Webhooks**. Same grouping/rate-limiting/redaction/blame path as
|
|
284
|
+
Telegram, formatted as Slack mrkdwn instead of HTML. Verify it's wired up:
|
|
285
|
+
|
|
286
|
+
```
|
|
287
|
+
uv run devalerts test --slack-webhook-url https://hooks.slack.com/services/...
|
|
288
|
+
```
|
|
289
|
+
|
|
208
290
|
## Why not Sentry?
|
|
209
291
|
|
|
210
292
|
If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
|
|
@@ -238,22 +320,27 @@ Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
|
|
|
238
320
|
Yes — call [`init_celery()`](#celery) in addition to `init()` to catch
|
|
239
321
|
exceptions raised inside tasks, which the excepthook alone won't see.
|
|
240
322
|
|
|
323
|
+
**Catches exceptions I log but don't re-raise?**
|
|
324
|
+
Yes — add [`LogHandler`](#logged-not-raised-exceptions) to a logger; the
|
|
325
|
+
excepthook alone never sees a caught-and-logged exception.
|
|
326
|
+
|
|
241
327
|
**Works on Windows / Linux / macOS?**
|
|
242
328
|
Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
|
|
243
329
|
paths.
|
|
244
330
|
|
|
245
331
|
## Privacy & Security
|
|
246
332
|
|
|
247
|
-
- The only network
|
|
248
|
-
telemetry, no analytics,
|
|
333
|
+
- The only network calls devalerts makes are to `api.telegram.org` and/or
|
|
334
|
+
your configured Slack incoming webhook — no telemetry, no analytics,
|
|
335
|
+
nothing else phones home.
|
|
249
336
|
- No third-party server and no devalerts-run backend — messages go straight
|
|
250
|
-
from your process to your own Telegram bot.
|
|
251
|
-
- No accounts, no signup, no API key beyond the bot token
|
|
252
|
-
control yourself.
|
|
337
|
+
from your process to your own Telegram bot / Slack webhook.
|
|
338
|
+
- No accounts, no signup, no API key beyond the bot token / webhook URL you
|
|
339
|
+
create and control yourself.
|
|
253
340
|
- Basic secret redaction only (a few common token/key patterns) — do not
|
|
254
341
|
rely on this for sensitive production data; scrub what you can before it
|
|
255
342
|
ever reaches an exception message.
|
|
256
|
-
- If
|
|
343
|
+
- If delivery fails after retrying, the alert (already redacted, if
|
|
257
344
|
`redact=True`) is appended to `~/.devalerts/failed.log` instead of being
|
|
258
345
|
dropped — clean it up like any other local log file.
|
|
259
346
|
|
|
@@ -266,7 +353,6 @@ paths.
|
|
|
266
353
|
## Roadmap
|
|
267
354
|
|
|
268
355
|
- Web dashboard (hosted, optional — the local CLI dashboard stays either way)
|
|
269
|
-
- Slack delivery
|
|
270
356
|
- Discord delivery
|
|
271
357
|
- Email delivery
|
|
272
358
|
|
|
@@ -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.4.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,55 @@
|
|
|
1
|
+
"""Crash-streak SVG badge rendering for `devalerts badge` -- no network call,
|
|
2
|
+
no shields.io dependency, just a hand-rolled flat badge."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from xml.sax.saxutils import escape
|
|
7
|
+
|
|
8
|
+
_BAND_COLORS = {
|
|
9
|
+
"grey": "#9f9f9f",
|
|
10
|
+
"red": "#e05d44",
|
|
11
|
+
"yellow": "#dfb317",
|
|
12
|
+
"green": "#4c1",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _streak_band(days: int | None) -> str:
|
|
17
|
+
if days is None:
|
|
18
|
+
return "grey"
|
|
19
|
+
if days < 1:
|
|
20
|
+
return "red"
|
|
21
|
+
if days < 7:
|
|
22
|
+
return "yellow"
|
|
23
|
+
return "green"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _streak_text(days: int | None) -> str:
|
|
27
|
+
if days is None:
|
|
28
|
+
return "no incidents yet"
|
|
29
|
+
if days < 1:
|
|
30
|
+
return "today"
|
|
31
|
+
return f"{days} day{'' if days == 1 else 's'}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _render_badge(label: str, days: int | None) -> str:
|
|
35
|
+
"""Flat shields.io-style badge. Character width is a fixed-per-glyph
|
|
36
|
+
approximation, not real font metrics -- fine for a README image, not
|
|
37
|
+
meant to be pixel-perfect."""
|
|
38
|
+
value = _streak_text(days)
|
|
39
|
+
color = _BAND_COLORS[_streak_band(days)]
|
|
40
|
+
label_width = 10 + 7 * len(label)
|
|
41
|
+
value_width = 10 + 7 * len(value)
|
|
42
|
+
width = label_width + value_width
|
|
43
|
+
height = 20
|
|
44
|
+
label_esc = escape(label)
|
|
45
|
+
value_esc = escape(value)
|
|
46
|
+
return (
|
|
47
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}">'
|
|
48
|
+
f'<rect width="{label_width}" height="{height}" fill="#555"/>'
|
|
49
|
+
f'<rect x="{label_width}" width="{value_width}" height="{height}" fill="{color}"/>'
|
|
50
|
+
f'<g fill="#fff" font-family="Verdana,Geneva,sans-serif" font-size="11">'
|
|
51
|
+
f'<text x="{label_width / 2}" y="14" text-anchor="middle">{label_esc}</text>'
|
|
52
|
+
f'<text x="{label_width + value_width / 2}" y="14" text-anchor="middle">'
|
|
53
|
+
f"{value_esc}</text>"
|
|
54
|
+
f"</g></svg>"
|
|
55
|
+
)
|
|
@@ -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,19 @@ 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
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _last_incident() -> float | None:
|
|
156
|
+
"""Unix timestamp of the most recent occurrence across all groups (sent or
|
|
157
|
+
suppressed -- last_seen is updated either way), or None if the state DB
|
|
158
|
+
has no groups recorded (fresh install, or everything cleared)."""
|
|
159
|
+
conn = _get_connection()
|
|
160
|
+
try:
|
|
161
|
+
row = conn.execute("SELECT MAX(last_seen) FROM error_groups").fetchone()
|
|
162
|
+
finally:
|
|
163
|
+
conn.close()
|
|
164
|
+
return row[0]
|
|
144
165
|
|
|
145
166
|
|
|
146
167
|
def _match_fingerprints(prefix: str) -> list[str]:
|
|
@@ -1,5 +1,7 @@
|
|
|
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; `devalerts badge` renders a 'days since last
|
|
4
|
+
incident' SVG badge."""
|
|
3
5
|
|
|
4
6
|
from __future__ import annotations
|
|
5
7
|
|
|
@@ -10,12 +12,16 @@ import sqlite3
|
|
|
10
12
|
import sys
|
|
11
13
|
import time
|
|
12
14
|
from importlib.metadata import version as _pkg_version
|
|
15
|
+
from pathlib import Path
|
|
13
16
|
|
|
17
|
+
from ._badge import _render_badge, _streak_band
|
|
18
|
+
from ._slack import _send_slack_message
|
|
14
19
|
from ._store import (
|
|
15
20
|
_DB_PATH,
|
|
16
21
|
_DEFAULT_RATE_LIMIT_SECONDS,
|
|
17
22
|
_clear,
|
|
18
23
|
_clear_all,
|
|
24
|
+
_last_incident,
|
|
19
25
|
_match_fingerprints,
|
|
20
26
|
_set_muted,
|
|
21
27
|
)
|
|
@@ -52,6 +58,23 @@ def _relative_time(ts: float, now: float) -> str:
|
|
|
52
58
|
return f"{int(delta // 86400)}d ago"
|
|
53
59
|
|
|
54
60
|
|
|
61
|
+
def _streak_line(last_incident: float, now: float, unicode_ok: bool) -> str:
|
|
62
|
+
days = int((now - last_incident) // 86400)
|
|
63
|
+
band = _streak_band(days)
|
|
64
|
+
icons = (
|
|
65
|
+
{"red": "🔴", "yellow": "🟡", "green": "🟢"}
|
|
66
|
+
if unicode_ok
|
|
67
|
+
else {"red": "[RESET]", "yellow": "[WARN]", "green": "[OK]"}
|
|
68
|
+
)
|
|
69
|
+
if band == "red":
|
|
70
|
+
return (
|
|
71
|
+
f"{icons['red']} New incident {_relative_time(last_incident, now)}"
|
|
72
|
+
" — streak reset to 0 days."
|
|
73
|
+
)
|
|
74
|
+
plural = "" if days == 1 else "s"
|
|
75
|
+
return f"{icons[band]} {days} day{plural} since the last incident."
|
|
76
|
+
|
|
77
|
+
|
|
55
78
|
def _color_enabled() -> bool:
|
|
56
79
|
if os.environ.get("NO_COLOR") is not None or not sys.stdout.isatty():
|
|
57
80
|
return False
|
|
@@ -155,6 +178,9 @@ def _dashboard(as_json: bool = False) -> int:
|
|
|
155
178
|
ellipsis = "…" if unicode_ok else "..."
|
|
156
179
|
times_char = "×" if unicode_ok else "x"
|
|
157
180
|
|
|
181
|
+
print(_streak_line(rows[0][3], now, unicode_ok))
|
|
182
|
+
print()
|
|
183
|
+
|
|
158
184
|
style = _Style(_color_enabled())
|
|
159
185
|
type_width = max(len("TYPE"), *(len(r[1]) for r in rows))
|
|
160
186
|
|
|
@@ -253,16 +279,53 @@ def _clear_command(prefix: str | None, clear_all: bool) -> int:
|
|
|
253
279
|
return 0
|
|
254
280
|
|
|
255
281
|
|
|
256
|
-
def
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
282
|
+
def _badge_command(out: str | None, label: str) -> int:
|
|
283
|
+
incident = _last_incident()
|
|
284
|
+
days = int((time.time() - incident) // 86400) if incident is not None else None
|
|
285
|
+
svg = _render_badge(label, days)
|
|
286
|
+
if out:
|
|
287
|
+
Path(out).write_text(svg, encoding="utf-8")
|
|
288
|
+
print(f"Wrote badge to {out}.")
|
|
289
|
+
else:
|
|
290
|
+
print(svg)
|
|
291
|
+
return 0
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _test(
|
|
295
|
+
bot_token: str | None, chat_id: str | None, slack_webhook_url: str | None
|
|
296
|
+
) -> int:
|
|
297
|
+
if bool(bot_token) != bool(chat_id):
|
|
298
|
+
print("--bot-token and --chat-id must be given together.", file=sys.stderr)
|
|
299
|
+
return 1
|
|
300
|
+
if not bot_token and not slack_webhook_url:
|
|
301
|
+
print(
|
|
302
|
+
"Provide --bot-token/--chat-id and/or --slack-webhook-url.",
|
|
303
|
+
file=sys.stderr,
|
|
304
|
+
)
|
|
305
|
+
return 1
|
|
306
|
+
ok = True
|
|
307
|
+
if bot_token and chat_id:
|
|
308
|
+
ok = (
|
|
309
|
+
_send_telegram_message(
|
|
310
|
+
bot_token,
|
|
311
|
+
chat_id,
|
|
312
|
+
"✅ devalerts test message -- bot_token and chat_id are wired up "
|
|
313
|
+
"correctly.",
|
|
314
|
+
)
|
|
315
|
+
and ok
|
|
316
|
+
)
|
|
317
|
+
if slack_webhook_url:
|
|
318
|
+
ok = (
|
|
319
|
+
_send_slack_message(
|
|
320
|
+
slack_webhook_url,
|
|
321
|
+
"✅ devalerts test message -- slack_webhook_url is wired up correctly.",
|
|
322
|
+
)
|
|
323
|
+
and ok
|
|
324
|
+
)
|
|
262
325
|
if not ok:
|
|
263
326
|
print("Failed to send test message (see error above).", file=sys.stderr)
|
|
264
327
|
return 1
|
|
265
|
-
print("Test message sent -- check your
|
|
328
|
+
print("Test message sent -- check your chat.")
|
|
266
329
|
return 0
|
|
267
330
|
|
|
268
331
|
|
|
@@ -279,10 +342,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
279
342
|
"--json", action="store_true", help="Output as JSON instead of a table"
|
|
280
343
|
)
|
|
281
344
|
test_parser = subparsers.add_parser(
|
|
282
|
-
"test",
|
|
345
|
+
"test",
|
|
346
|
+
help="Send a test message to verify bot_token/chat_id and/or slack_webhook_url",
|
|
283
347
|
)
|
|
284
|
-
test_parser.add_argument("--bot-token"
|
|
285
|
-
test_parser.add_argument("--chat-id"
|
|
348
|
+
test_parser.add_argument("--bot-token")
|
|
349
|
+
test_parser.add_argument("--chat-id")
|
|
350
|
+
test_parser.add_argument("--slack-webhook-url")
|
|
286
351
|
mute_parser = subparsers.add_parser("mute", help="Silence a specific error group")
|
|
287
352
|
mute_parser.add_argument(
|
|
288
353
|
"fingerprint", help="Fingerprint or unique prefix (dashboard ID column)"
|
|
@@ -301,18 +366,31 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
301
366
|
clear_target.add_argument(
|
|
302
367
|
"--all", action="store_true", help="Delete all error groups"
|
|
303
368
|
)
|
|
369
|
+
badge_parser = subparsers.add_parser(
|
|
370
|
+
"badge", help="Print or write an SVG 'days since last incident' badge"
|
|
371
|
+
)
|
|
372
|
+
badge_parser.add_argument(
|
|
373
|
+
"--out", help="Write the SVG to this path instead of stdout"
|
|
374
|
+
)
|
|
375
|
+
badge_parser.add_argument(
|
|
376
|
+
"--label",
|
|
377
|
+
default="crash streak",
|
|
378
|
+
help="Badge label text (default: 'crash streak')",
|
|
379
|
+
)
|
|
304
380
|
args = parser.parse_args(argv)
|
|
305
381
|
|
|
306
382
|
if args.command == "dashboard":
|
|
307
383
|
return _dashboard(as_json=args.json)
|
|
308
384
|
if args.command == "test":
|
|
309
|
-
return _test(args.bot_token, args.chat_id)
|
|
385
|
+
return _test(args.bot_token, args.chat_id, args.slack_webhook_url)
|
|
310
386
|
if args.command == "mute":
|
|
311
387
|
return _mute_command(args.fingerprint, muted=True)
|
|
312
388
|
if args.command == "unmute":
|
|
313
389
|
return _mute_command(args.fingerprint, muted=False)
|
|
314
390
|
if args.command == "clear":
|
|
315
391
|
return _clear_command(args.fingerprint, args.all)
|
|
392
|
+
if args.command == "badge":
|
|
393
|
+
return _badge_command(args.out, args.label)
|
|
316
394
|
return 1
|
|
317
395
|
|
|
318
396
|
|
|
@@ -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
|