devalerts 0.2.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devalerts
3
- Version: 0.2.2
4
- Summary: Send unhandled Python exceptions straight to a Telegram chat. No backend, no account, no database — just your own bot token.
3
+ Version: 0.3.0
4
+ Summary: Send unhandled Python exceptions straight to a Telegram chat (or Slack). No backend, no account, no database — just your own bot token or webhook URL.
5
5
  Keywords: telegram,error-tracking,exception-monitoring,alerts,logging,monitoring,asgi
6
6
  Author: sslinNn
7
7
  Author-email: sslinNn <morison1991@mail.ru>
@@ -28,7 +28,7 @@ Description-Content-Type: text/markdown
28
28
  # devalerts
29
29
 
30
30
  [![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
@@ -51,6 +51,8 @@ That's the whole setup. Two minutes with [@BotFather](https://t.me/BotFather)
51
51
  and every unhandled crash — including ones raised in threads — lands in your
52
52
  chat instead of a log file nobody's watching.
53
53
 
54
+ ![devalerts demo](https://raw.githubusercontent.com/sslinNn/devalerts/main/docs/demo.gif)
55
+
54
56
  ## Why devalerts
55
57
 
56
58
  - **Zero infrastructure.** No SaaS signup, no ingestion server, no API key
@@ -230,6 +232,60 @@ grouping/rate-limiting/redaction path as everything else, tagged with the
230
232
  task name and id automatically. Requires Celery to already be installed in
231
233
  the worker process — it's imported lazily, not a devalerts dependency.
232
234
 
235
+ ## Logged (not raised) exceptions
236
+
237
+ `init()`'s excepthook only ever sees exceptions that stay unhandled — but most
238
+ real errors are caught and logged instead: `logger.exception(...)` inside a
239
+ `try/except` never propagates anywhere. Attach `LogHandler` to catch those too:
240
+
241
+ ```python
242
+ import logging
243
+
244
+ logging.getLogger().addHandler(devalerts.LogHandler())
245
+ ```
246
+
247
+ A record with `exc_info` (`logger.exception()`, or `logger.error(...,
248
+ exc_info=True)`) is reported like a full exception alert — grouped by the
249
+ same fingerprint an unhandled instance of it would use, so logging it and
250
+ then re-raising sends one alert, not two. A plain `logger.error("message")`
251
+ with no exception attached is reported as a short text alert, grouped by
252
+ logger name + level + message. Defaults to `level=logging.ERROR`; pass
253
+ `extra={...}` for tags added to every alert from this handler.
254
+
255
+ ## Blame
256
+
257
+ Curious *who* wrote the line that just blew up? `blame=True` runs `git blame`
258
+ on it and adds the author, short commit hash, and date to the alert:
259
+
260
+ ```python
261
+ devalerts.init(bot_token="...", chat_id=123456789, blame=True)
262
+ ```
263
+
264
+ ```
265
+ 🕵️ blame: sslinNn · a1b2c3d · 2026-07-15 (3d ago)
266
+ ```
267
+
268
+ Best-effort and silent about it — no git installed, no repo (a container
269
+ image without `.git`), or the line isn't committed yet all just skip the
270
+ line instead of failing the alert. Off by default; opt in with `blame=True`.
271
+
272
+ ## Slack
273
+
274
+ Prefer Slack, or want both? Pass a Slack incoming webhook URL alongside or
275
+ instead of `bot_token`/`chat_id` — every configured channel gets every alert:
276
+
277
+ ```python
278
+ devalerts.init(slack_webhook_url="https://hooks.slack.com/services/...")
279
+ ```
280
+
281
+ Create one at [api.slack.com/apps](https://api.slack.com/apps) → your app →
282
+ **Incoming Webhooks**. Same grouping/rate-limiting/redaction/blame path as
283
+ Telegram, formatted as Slack mrkdwn instead of HTML. Verify it's wired up:
284
+
285
+ ```
286
+ uv run devalerts test --slack-webhook-url https://hooks.slack.com/services/...
287
+ ```
288
+
233
289
  ## Why not Sentry?
234
290
 
235
291
  If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
@@ -263,22 +319,27 @@ Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
263
319
  Yes — call [`init_celery()`](#celery) in addition to `init()` to catch
264
320
  exceptions raised inside tasks, which the excepthook alone won't see.
265
321
 
322
+ **Catches exceptions I log but don't re-raise?**
323
+ Yes — add [`LogHandler`](#logged-not-raised-exceptions) to a logger; the
324
+ excepthook alone never sees a caught-and-logged exception.
325
+
266
326
  **Works on Windows / Linux / macOS?**
267
327
  Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
268
328
  paths.
269
329
 
270
330
  ## Privacy & Security
271
331
 
272
- - The only network call devalerts makes is to `api.telegram.org` — no
273
- telemetry, no analytics, nothing else phones home.
332
+ - The only network calls devalerts makes are to `api.telegram.org` and/or
333
+ your configured Slack incoming webhook — no telemetry, no analytics,
334
+ nothing else phones home.
274
335
  - No third-party server and no devalerts-run backend — messages go straight
275
- from your process to your own Telegram bot.
276
- - No accounts, no signup, no API key beyond the bot token you create and
277
- control yourself.
336
+ from your process to your own Telegram bot / Slack webhook.
337
+ - No accounts, no signup, no API key beyond the bot token / webhook URL you
338
+ create and control yourself.
278
339
  - Basic secret redaction only (a few common token/key patterns) — do not
279
340
  rely on this for sensitive production data; scrub what you can before it
280
341
  ever reaches an exception message.
281
- - If Telegram delivery fails after retrying, the alert (already redacted, if
342
+ - If delivery fails after retrying, the alert (already redacted, if
282
343
  `redact=True`) is appended to `~/.devalerts/failed.log` instead of being
283
344
  dropped — clean it up like any other local log file.
284
345
 
@@ -291,7 +352,6 @@ paths.
291
352
  ## Roadmap
292
353
 
293
354
  - Web dashboard (hosted, optional — the local CLI dashboard stays either way)
294
- - Slack delivery
295
355
  - Discord delivery
296
356
  - Email delivery
297
357
 
@@ -1,7 +1,7 @@
1
1
  # devalerts
2
2
 
3
3
  [![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
@@ -24,6 +24,8 @@ That's the whole setup. Two minutes with [@BotFather](https://t.me/BotFather)
24
24
  and every unhandled crash — including ones raised in threads — lands in your
25
25
  chat instead of a log file nobody's watching.
26
26
 
27
+ ![devalerts demo](https://raw.githubusercontent.com/sslinNn/devalerts/main/docs/demo.gif)
28
+
27
29
  ## Why devalerts
28
30
 
29
31
  - **Zero infrastructure.** No SaaS signup, no ingestion server, no API key
@@ -203,6 +205,60 @@ grouping/rate-limiting/redaction path as everything else, tagged with the
203
205
  task name and id automatically. Requires Celery to already be installed in
204
206
  the worker process — it's imported lazily, not a devalerts dependency.
205
207
 
208
+ ## Logged (not raised) exceptions
209
+
210
+ `init()`'s excepthook only ever sees exceptions that stay unhandled — but most
211
+ real errors are caught and logged instead: `logger.exception(...)` inside a
212
+ `try/except` never propagates anywhere. Attach `LogHandler` to catch those too:
213
+
214
+ ```python
215
+ import logging
216
+
217
+ logging.getLogger().addHandler(devalerts.LogHandler())
218
+ ```
219
+
220
+ A record with `exc_info` (`logger.exception()`, or `logger.error(...,
221
+ exc_info=True)`) is reported like a full exception alert — grouped by the
222
+ same fingerprint an unhandled instance of it would use, so logging it and
223
+ then re-raising sends one alert, not two. A plain `logger.error("message")`
224
+ with no exception attached is reported as a short text alert, grouped by
225
+ logger name + level + message. Defaults to `level=logging.ERROR`; pass
226
+ `extra={...}` for tags added to every alert from this handler.
227
+
228
+ ## Blame
229
+
230
+ Curious *who* wrote the line that just blew up? `blame=True` runs `git blame`
231
+ on it and adds the author, short commit hash, and date to the alert:
232
+
233
+ ```python
234
+ devalerts.init(bot_token="...", chat_id=123456789, blame=True)
235
+ ```
236
+
237
+ ```
238
+ 🕵️ blame: sslinNn · a1b2c3d · 2026-07-15 (3d ago)
239
+ ```
240
+
241
+ Best-effort and silent about it — no git installed, no repo (a container
242
+ image without `.git`), or the line isn't committed yet all just skip the
243
+ line instead of failing the alert. Off by default; opt in with `blame=True`.
244
+
245
+ ## Slack
246
+
247
+ Prefer Slack, or want both? Pass a Slack incoming webhook URL alongside or
248
+ instead of `bot_token`/`chat_id` — every configured channel gets every alert:
249
+
250
+ ```python
251
+ devalerts.init(slack_webhook_url="https://hooks.slack.com/services/...")
252
+ ```
253
+
254
+ Create one at [api.slack.com/apps](https://api.slack.com/apps) → your app →
255
+ **Incoming Webhooks**. Same grouping/rate-limiting/redaction/blame path as
256
+ Telegram, formatted as Slack mrkdwn instead of HTML. Verify it's wired up:
257
+
258
+ ```
259
+ uv run devalerts test --slack-webhook-url https://hooks.slack.com/services/...
260
+ ```
261
+
206
262
  ## Why not Sentry?
207
263
 
208
264
  If you already run Sentry/Rollbar/etc., keep using it — devalerts isn't a
@@ -236,22 +292,27 @@ Yes — `init()` installs both `sys.excepthook` and `threading.excepthook`.
236
292
  Yes — call [`init_celery()`](#celery) in addition to `init()` to catch
237
293
  exceptions raised inside tasks, which the excepthook alone won't see.
238
294
 
295
+ **Catches exceptions I log but don't re-raise?**
296
+ Yes — add [`LogHandler`](#logged-not-raised-exceptions) to a logger; the
297
+ excepthook alone never sees a caught-and-logged exception.
298
+
239
299
  **Works on Windows / Linux / macOS?**
240
300
  Yes — stdlib only (`urllib`, `sqlite3`, `threading`), no OS-specific code
241
301
  paths.
242
302
 
243
303
  ## Privacy & Security
244
304
 
245
- - The only network call devalerts makes is to `api.telegram.org` — no
246
- telemetry, no analytics, nothing else phones home.
305
+ - The only network calls devalerts makes are to `api.telegram.org` and/or
306
+ your configured Slack incoming webhook — no telemetry, no analytics,
307
+ nothing else phones home.
247
308
  - No third-party server and no devalerts-run backend — messages go straight
248
- from your process to your own Telegram bot.
249
- - No accounts, no signup, no API key beyond the bot token you create and
250
- control yourself.
309
+ from your process to your own Telegram bot / Slack webhook.
310
+ - No accounts, no signup, no API key beyond the bot token / webhook URL you
311
+ create and control yourself.
251
312
  - Basic secret redaction only (a few common token/key patterns) — do not
252
313
  rely on this for sensitive production data; scrub what you can before it
253
314
  ever reaches an exception message.
254
- - If Telegram delivery fails after retrying, the alert (already redacted, if
315
+ - If delivery fails after retrying, the alert (already redacted, if
255
316
  `redact=True`) is appended to `~/.devalerts/failed.log` instead of being
256
317
  dropped — clean it up like any other local log file.
257
318
 
@@ -264,7 +325,6 @@ paths.
264
325
  ## Roadmap
265
326
 
266
327
  - Web dashboard (hosted, optional — the local CLI dashboard stays either way)
267
- - Slack delivery
268
328
  - Discord delivery
269
329
  - Email delivery
270
330
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "devalerts"
3
- version = "0.2.2"
4
- description = "Send unhandled Python exceptions straight to a Telegram chat. No backend, no account, no database — just your own bot token."
3
+ version = "0.3.0"
4
+ description = "Send unhandled Python exceptions straight to a Telegram chat (or Slack). No backend, no account, no database — just your own bot token or webhook URL."
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
@@ -1,19 +1,40 @@
1
- """Send unhandled Python exceptions straight to a Telegram chat."""
1
+ """Send unhandled Python exceptions straight to a Telegram chat and/or Slack."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import contextlib
6
+ import logging
6
7
  import sys
7
8
  import threading
8
9
  from types import TracebackType
9
10
  from typing import Callable, Literal, Optional, TypedDict
10
11
 
11
- from ._alert import _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,82 @@
1
+ """Best-effort ``git blame`` lookup for the line that raised. Never raises --
2
+ returns None on any failure (no git installed, not a repo, uncommitted line,
3
+ timeout, ...)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import subprocess
8
+ import time
9
+ import traceback
10
+ from pathlib import Path
11
+ from types import TracebackType
12
+
13
+ _TIMEOUT_SECONDS = 2
14
+
15
+
16
+ def _relative_time(seconds_ago: float) -> str:
17
+ seconds_ago = max(seconds_ago, 0)
18
+ minutes = seconds_ago / 60
19
+ if minutes < 1:
20
+ return "just now"
21
+ if minutes < 60:
22
+ return f"{int(minutes)}m ago"
23
+ hours = minutes / 60
24
+ if hours < 24:
25
+ return f"{int(hours)}h ago"
26
+ days = hours / 24
27
+ if days < 30:
28
+ return f"{int(days)}d ago"
29
+ months = days / 30
30
+ if months < 12:
31
+ return f"{int(months)}mo ago"
32
+ return f"{int(months / 12)}y ago"
33
+
34
+
35
+ def _git_blame(filename: str, lineno: int) -> str | None:
36
+ try:
37
+ result = subprocess.run(
38
+ [
39
+ "git",
40
+ "-C",
41
+ str(Path(filename).parent),
42
+ "blame",
43
+ "-L",
44
+ f"{lineno},{lineno}",
45
+ "--porcelain",
46
+ "--",
47
+ filename,
48
+ ],
49
+ capture_output=True,
50
+ text=True,
51
+ timeout=_TIMEOUT_SECONDS,
52
+ )
53
+ if result.returncode != 0 or not result.stdout:
54
+ return None
55
+ lines = result.stdout.splitlines()
56
+ sha = lines[0].split()[0]
57
+ author: str | None = None
58
+ author_time: int | None = None
59
+ for line in lines[1:]:
60
+ if line.startswith("author "):
61
+ author = line[len("author ") :]
62
+ elif line.startswith("author-time "):
63
+ author_time = int(line[len("author-time ") :])
64
+ elif line.startswith("\t"):
65
+ break
66
+ if not author or author_time is None or author == "Not Committed Yet":
67
+ return None
68
+ date = time.strftime("%Y-%m-%d", time.localtime(author_time))
69
+ age = _relative_time(time.time() - author_time)
70
+ return f"{author} · {sha[:7]} · {date} ({age})"
71
+ except Exception: # noqa: BLE001 - best-effort, must never raise
72
+ return None
73
+
74
+
75
+ def _git_blame_for_traceback(tb: TracebackType | None) -> str | None:
76
+ frames = traceback.extract_tb(tb)
77
+ if not frames:
78
+ return None
79
+ lineno = frames[-1].lineno
80
+ if lineno is None:
81
+ return None
82
+ return _git_blame(frames[-1].filename, lineno)
@@ -0,0 +1,42 @@
1
+ """Slack incoming-webhook delivery -- retries transient failures, logs to the
2
+ same local fallback file Telegram delivery uses if every attempt fails. Never
3
+ raises."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import sys
9
+ import time
10
+ import urllib.error
11
+ import urllib.request
12
+
13
+ from ._telegram import _log_failed_delivery
14
+
15
+ _TIMEOUT_SECONDS = 5
16
+ _MAX_ATTEMPTS = 2
17
+ _RETRY_BACKOFF_SECONDS = 1.0
18
+
19
+
20
+ def _send_slack_message(webhook_url: str, text: str) -> bool:
21
+ payload = json.dumps({"text": text}).encode("utf-8")
22
+ last_error: Exception | None = None
23
+
24
+ for attempt in range(_MAX_ATTEMPTS):
25
+ request = urllib.request.Request(
26
+ webhook_url,
27
+ data=payload,
28
+ headers={"Content-Type": "application/json"},
29
+ method="POST",
30
+ )
31
+ try:
32
+ urllib.request.urlopen(request, timeout=_TIMEOUT_SECONDS)
33
+ return True
34
+ except (urllib.error.URLError, OSError, ValueError) as error:
35
+ last_error = error
36
+
37
+ if attempt < _MAX_ATTEMPTS - 1:
38
+ time.sleep(_RETRY_BACKOFF_SECONDS)
39
+
40
+ print(f"devalerts: failed to send Slack alert: {last_error}", file=sys.stderr)
41
+ _log_failed_delivery(text)
42
+ return False
@@ -24,6 +24,14 @@ def _fingerprint(exc_type, tb) -> tuple[str, str]:
24
24
  return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16], location
25
25
 
26
26
 
27
+ def _fingerprint_log(
28
+ logger_name: str, level: int, msg: str, pathname: str, lineno: int
29
+ ) -> tuple[str, str]:
30
+ location = f"{pathname}:{lineno}"
31
+ raw = f"{logger_name}:{level}:{msg}:{location}"
32
+ return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16], location
33
+
34
+
27
35
  def _get_connection() -> sqlite3.Connection:
28
36
  _DB_PATH.parent.mkdir(parents=True, exist_ok=True)
29
37
  conn = sqlite3.connect(_DB_PATH, timeout=5)
@@ -62,7 +70,7 @@ def _get_connection() -> sqlite3.Connection:
62
70
 
63
71
  def _should_send(
64
72
  fingerprint: str, exc_type_name: str, location: str, rate_limit_seconds: int
65
- ) -> tuple[bool, int]:
73
+ ) -> tuple[bool, int, bool]:
66
74
  now = time.time()
67
75
  try:
68
76
  conn = _get_connection()
@@ -73,6 +81,7 @@ def _should_send(
73
81
  "FROM error_groups WHERE fingerprint = ?",
74
82
  (fingerprint,),
75
83
  ).fetchone()
84
+ is_new = row is None
76
85
  muted = bool(row[2]) if row else False
77
86
  multiplier = row[3] if row else 1
78
87
  effective_window = rate_limit_seconds * multiplier
@@ -130,7 +139,7 @@ def _should_send(
130
139
  "DELETE FROM error_groups WHERE last_seen < ?",
131
140
  (now - _RETENTION_SECONDS,),
132
141
  )
133
- return send, skipped
142
+ return send, skipped, is_new
134
143
  finally:
135
144
  conn.close()
136
145
  except (sqlite3.Error, OSError) as error:
@@ -140,7 +149,7 @@ def _should_send(
140
149
  f"devalerts: dedup/rate-limit state error, sending anyway: {error}",
141
150
  file=sys.stderr,
142
151
  )
143
- return True, 0
152
+ return True, 0, True
144
153
 
145
154
 
146
155
  def _match_fingerprints(prefix: str) -> list[str]:
@@ -1,5 +1,6 @@
1
1
  """CLI: `devalerts dashboard` reports grouped/rate-limited errors from the local
2
- state DB; `devalerts test` sends a one-off message to verify bot_token/chat_id."""
2
+ state DB; `devalerts test` sends a one-off message to verify bot_token/chat_id
3
+ and/or slack_webhook_url."""
3
4
 
4
5
  from __future__ import annotations
5
6
 
@@ -11,6 +12,7 @@ import sys
11
12
  import time
12
13
  from importlib.metadata import version as _pkg_version
13
14
 
15
+ from ._slack import _send_slack_message
14
16
  from ._store import (
15
17
  _DB_PATH,
16
18
  _DEFAULT_RATE_LIMIT_SECONDS,
@@ -27,7 +29,7 @@ _LOCATION_WIDTH = 34
27
29
  def _supports_unicode() -> bool:
28
30
  encoding = getattr(sys.stdout, "encoding", None) or ""
29
31
  try:
30
- "─●…".encode(encoding)
32
+ "─●…×".encode(encoding)
31
33
  return True
32
34
  except (LookupError, UnicodeEncodeError, TypeError):
33
35
  return False
@@ -153,6 +155,7 @@ def _dashboard(as_json: bool = False) -> int:
153
155
  sep_char = "─" if unicode_ok else "-"
154
156
  dot_char = "●" if unicode_ok else "*"
155
157
  ellipsis = "…" if unicode_ok else "..."
158
+ times_char = "×" if unicode_ok else "x"
156
159
 
157
160
  style = _Style(_color_enabled())
158
161
  type_width = max(len("TYPE"), *(len(r[1]) for r in rows))
@@ -178,7 +181,9 @@ def _dashboard(as_json: bool = False) -> int:
178
181
  muted,
179
182
  backoff_multiplier,
180
183
  ) in rows:
181
- backoff_suffix = f" ×{backoff_multiplier}" if backoff_multiplier > 1 else ""
184
+ backoff_suffix = (
185
+ f" {times_char}{backoff_multiplier}" if backoff_multiplier > 1 else ""
186
+ )
182
187
  if muted:
183
188
  muted_count += 1
184
189
  status = style.dim(f"{dot_char} muted")
@@ -250,16 +255,41 @@ def _clear_command(prefix: str | None, clear_all: bool) -> int:
250
255
  return 0
251
256
 
252
257
 
253
- def _test(bot_token: str, chat_id: str) -> int:
254
- ok = _send_telegram_message(
255
- bot_token,
256
- chat_id,
257
- "✅ devalerts test message -- bot_token and chat_id are wired up correctly.",
258
- )
258
+ def _test(
259
+ bot_token: str | None, chat_id: str | None, slack_webhook_url: str | None
260
+ ) -> int:
261
+ if bool(bot_token) != bool(chat_id):
262
+ print("--bot-token and --chat-id must be given together.", file=sys.stderr)
263
+ return 1
264
+ if not bot_token and not slack_webhook_url:
265
+ print(
266
+ "Provide --bot-token/--chat-id and/or --slack-webhook-url.",
267
+ file=sys.stderr,
268
+ )
269
+ return 1
270
+ ok = True
271
+ if bot_token and chat_id:
272
+ ok = (
273
+ _send_telegram_message(
274
+ bot_token,
275
+ chat_id,
276
+ "✅ devalerts test message -- bot_token and chat_id are wired up "
277
+ "correctly.",
278
+ )
279
+ and ok
280
+ )
281
+ if slack_webhook_url:
282
+ ok = (
283
+ _send_slack_message(
284
+ slack_webhook_url,
285
+ "✅ devalerts test message -- slack_webhook_url is wired up correctly.",
286
+ )
287
+ and ok
288
+ )
259
289
  if not ok:
260
290
  print("Failed to send test message (see error above).", file=sys.stderr)
261
291
  return 1
262
- print("Test message sent -- check your Telegram chat.")
292
+ print("Test message sent -- check your chat.")
263
293
  return 0
264
294
 
265
295
 
@@ -276,10 +306,12 @@ def main(argv: list[str] | None = None) -> int:
276
306
  "--json", action="store_true", help="Output as JSON instead of a table"
277
307
  )
278
308
  test_parser = subparsers.add_parser(
279
- "test", help="Send a test message to verify bot_token/chat_id"
309
+ "test",
310
+ help="Send a test message to verify bot_token/chat_id and/or slack_webhook_url",
280
311
  )
281
- test_parser.add_argument("--bot-token", required=True)
282
- test_parser.add_argument("--chat-id", required=True)
312
+ test_parser.add_argument("--bot-token")
313
+ test_parser.add_argument("--chat-id")
314
+ test_parser.add_argument("--slack-webhook-url")
283
315
  mute_parser = subparsers.add_parser("mute", help="Silence a specific error group")
284
316
  mute_parser.add_argument(
285
317
  "fingerprint", help="Fingerprint or unique prefix (dashboard ID column)"
@@ -303,7 +335,7 @@ def main(argv: list[str] | None = None) -> int:
303
335
  if args.command == "dashboard":
304
336
  return _dashboard(as_json=args.json)
305
337
  if args.command == "test":
306
- return _test(args.bot_token, args.chat_id)
338
+ return _test(args.bot_token, args.chat_id, args.slack_webhook_url)
307
339
  if args.command == "mute":
308
340
  return _mute_command(args.fingerprint, muted=True)
309
341
  if args.command == "unmute":
@@ -1,71 +0,0 @@
1
- """Alert message formatting and secret redaction."""
2
-
3
- from __future__ import annotations
4
-
5
- import re
6
- import socket
7
- import traceback
8
-
9
- _MAX_MESSAGE_LENGTH = 4096
10
-
11
-
12
- def _escape_html(text: str) -> str:
13
- return text.replace("&", "&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