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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devalerts
3
- Version: 0.2.3
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
  [![PyPI](https://img.shields.io/pypi/v/devalerts)](https://pypi.org/project/devalerts/)
31
- [![Downloads](https://img.shields.io/pypi/dm/devalerts)](https://pypi.org/project/devalerts/)
31
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/devalerts?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=BLUE&left_text=downloads)](https://pepy.tech/projects/devalerts)
32
32
  [![Python versions](https://img.shields.io/pypi/pyversions/devalerts)](https://pypi.org/project/devalerts/)
33
33
  [![License: MIT](https://img.shields.io/pypi/l/devalerts)](LICENSE)
34
34
  [![Tests](https://img.shields.io/github/actions/workflow/status/sslinNn/devalerts/test.yml?branch=main&label=tests)](https://github.com/sslinNn/devalerts/actions/workflows/test.yml)
@@ -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 the moment
41
- they happen, on your phone. No backend, no account, no database — just your
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
+ ![devalerts crash streak badge](https://raw.githubusercontent.com/sslinNn/devalerts/main/docs/streak-badge.svg)
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 call devalerts makes is to `api.telegram.org` — no
275
- telemetry, no analytics, nothing else phones home.
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 you create and
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 Telegram delivery fails after retrying, the alert (already redacted, 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
  [![PyPI](https://img.shields.io/pypi/v/devalerts)](https://pypi.org/project/devalerts/)
4
- [![Downloads](https://img.shields.io/pypi/dm/devalerts)](https://pypi.org/project/devalerts/)
4
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/devalerts?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=BLUE&left_text=downloads)](https://pepy.tech/projects/devalerts)
5
5
  [![Python versions](https://img.shields.io/pypi/pyversions/devalerts)](https://pypi.org/project/devalerts/)
6
6
  [![License: MIT](https://img.shields.io/pypi/l/devalerts)](LICENSE)
7
7
  [![Tests](https://img.shields.io/github/actions/workflow/status/sslinNn/devalerts/test.yml?branch=main&label=tests)](https://github.com/sslinNn/devalerts/actions/workflows/test.yml)
@@ -10,9 +10,9 @@
10
10
 
11
11
  [Русская версия](README.ru.md)
12
12
 
13
- Send unhandled Python exceptions straight to a Telegram chat the moment
14
- they happen, on your phone. No backend, no account, no database — just your
15
- own bot token.
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
+ ![devalerts crash streak badge](https://raw.githubusercontent.com/sslinNn/devalerts/main/docs/streak-badge.svg)
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 call devalerts makes is to `api.telegram.org` — no
248
- telemetry, no analytics, nothing else phones home.
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 you create and
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 Telegram delivery fails after retrying, the alert (already redacted, 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.2.3"
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 _format_alert, _redact
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 ._store import _DEFAULT_RATE_LIMIT_SECONDS, _fingerprint, _should_send
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] = ["init", "report", "capture", "ASGIMiddleware", "init_celery"]
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 _state["bot_token"] is None or _state["chat_id"] is None:
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
- message = _format_alert(exc_type, exc_value, tb, skipped=skipped, tags=tags)
63
- if _state["redact"]:
64
- message = _redact(message)
65
- _send_telegram_message(_state["bot_token"], _state["chat_id"], message)
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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 _test(bot_token: str, chat_id: str) -> int:
257
- ok = _send_telegram_message(
258
- bot_token,
259
- chat_id,
260
- "✅ devalerts test message -- bot_token and chat_id are wired up correctly.",
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 Telegram chat.")
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", help="Send a test message to verify bot_token/chat_id"
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", required=True)
285
- test_parser.add_argument("--chat-id", required=True)
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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