devalerts 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devalerts
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Send unhandled Python exceptions straight to a Telegram chat. No backend, no account, no database — just your own bot token.
5
5
  Keywords: telegram,error-tracking,exception-monitoring,alerts,logging,monitoring,asgi
6
6
  Author: sslinNn
@@ -94,6 +94,10 @@ devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
94
94
  That's it — any unhandled exception (including ones raised in threads) now
95
95
  also lands in your Telegram chat.
96
96
 
97
+ The traceback itself arrives folded into a collapsed quote — the exception
98
+ type, message, and host are visible right away, tap to expand the full
99
+ traceback. Keeps a big stack trace from taking over the chat.
100
+
97
101
  ## Grouping, rate limiting, and the dashboard
98
102
 
99
103
  Exceptions are grouped by fingerprint (exception type + file + line where it
@@ -147,15 +151,27 @@ devalerts.init(bot_token="...", chat_id=123456789, tags={"env": "production"})
147
151
  🖥️ prod-web-2 (env=production)
148
152
  ```
149
153
 
150
- Add ad-hoc tags to a single call — they override `init()`'s tags on a key
151
- collision:
154
+ Add ad-hoc tags to a single call — they override `init()`'s tags (and each
155
+ other) on a key collision:
152
156
 
153
157
  ```python
154
158
  devalerts.report(extra={"request_id": "abc123"})
159
+ ```
160
+
161
+ Used as a decorator, `capture()` tags the alert with the wrapped function's
162
+ name as `job` automatically — no `extra` needed:
163
+
164
+ ```python
165
+ @devalerts.capture()
166
+ def nightly_sync(): ...
167
+ ```
155
168
 
156
- @devalerts.capture(extra={"job": "nightly-sync"})
157
- def run_job(): ...
158
169
  ```
170
+ 🔴 ValueError: boom
171
+ 🖥️ prod-web-2 (job=nightly_sync)
172
+ ```
173
+
174
+ Pass `extra={"job": "..."}` explicitly to override the auto-detected name.
159
175
 
160
176
  ## Manually reporting a caught exception
161
177
 
@@ -67,6 +67,10 @@ devalerts.init(bot_token="123456:ABC-DEF...", chat_id=123456789)
67
67
  That's it — any unhandled exception (including ones raised in threads) now
68
68
  also lands in your Telegram chat.
69
69
 
70
+ The traceback itself arrives folded into a collapsed quote — the exception
71
+ type, message, and host are visible right away, tap to expand the full
72
+ traceback. Keeps a big stack trace from taking over the chat.
73
+
70
74
  ## Grouping, rate limiting, and the dashboard
71
75
 
72
76
  Exceptions are grouped by fingerprint (exception type + file + line where it
@@ -120,15 +124,27 @@ devalerts.init(bot_token="...", chat_id=123456789, tags={"env": "production"})
120
124
  🖥️ prod-web-2 (env=production)
121
125
  ```
122
126
 
123
- Add ad-hoc tags to a single call — they override `init()`'s tags on a key
124
- collision:
127
+ Add ad-hoc tags to a single call — they override `init()`'s tags (and each
128
+ other) on a key collision:
125
129
 
126
130
  ```python
127
131
  devalerts.report(extra={"request_id": "abc123"})
132
+ ```
133
+
134
+ Used as a decorator, `capture()` tags the alert with the wrapped function's
135
+ name as `job` automatically — no `extra` needed:
136
+
137
+ ```python
138
+ @devalerts.capture()
139
+ def nightly_sync(): ...
140
+ ```
128
141
 
129
- @devalerts.capture(extra={"job": "nightly-sync"})
130
- def run_job(): ...
131
142
  ```
143
+ 🔴 ValueError: boom
144
+ 🖥️ prod-web-2 (job=nightly_sync)
145
+ ```
146
+
147
+ Pass `extra={"job": "..."}` explicitly to override the auto-detected name.
132
148
 
133
149
  ## Manually reporting a caught exception
134
150
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devalerts"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Send unhandled Python exceptions straight to a Telegram chat. No backend, no account, no database — just your own bot token."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -130,17 +130,29 @@ def report(
130
130
  class capture(contextlib.ContextDecorator):
131
131
  """Context manager / decorator: report any exception raised inside the block or
132
132
  function, then re-raise it. Use ``@capture()`` on a function instead of wrapping
133
- its body in a manual ``try/except`` or ``with`` block."""
133
+ its body in a manual ``try/except`` or ``with`` block.
134
+
135
+ Used as a decorator, the wrapped function's name is tagged automatically as
136
+ ``job`` -- no need to pass ``extra={"job": ...}`` by hand. Used as a bare
137
+ ``with capture():`` block, there's no function to name, so only explicit
138
+ ``extra`` tags apply."""
134
139
 
135
140
  def __init__(self, *, extra: dict[str, str] | None = None) -> None:
136
141
  self._extra = extra
142
+ self._job_name: str | None = None
143
+
144
+ def __call__(self, func):
145
+ self._job_name = func.__qualname__
146
+ return super().__call__(func)
137
147
 
138
148
  def __enter__(self) -> "capture":
139
149
  return self
140
150
 
141
151
  def __exit__(self, exc_type, exc_value, tb) -> Literal[False]:
142
152
  if exc_type is not None:
143
- _send_exception(exc_type, exc_value, tb, extra=self._extra)
153
+ tags = {"job": self._job_name} if self._job_name else {}
154
+ tags.update(self._extra or {})
155
+ _send_exception(exc_type, exc_value, tb, extra=tags)
144
156
  return False
145
157
 
146
158
 
@@ -9,6 +9,10 @@ import traceback
9
9
  _MAX_MESSAGE_LENGTH = 4096
10
10
 
11
11
 
12
+ def _escape_html(text: str) -> str:
13
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
14
+
15
+
12
16
  def _format_context(tags: dict[str, str] | None) -> str:
13
17
  try:
14
18
  hostname = socket.gethostname()
@@ -27,16 +31,25 @@ def _format_alert(
27
31
  if skipped:
28
32
  header += f"\n⚠️ Повторилась ещё {skipped} раз(а) с последнего алерта"
29
33
  body = "".join(traceback.format_exception(exc_type, exc_value, tb))
30
- message = f"{header}\n\n{body}"
31
- if len(message) <= _MAX_MESSAGE_LENGTH:
32
- return message
33
- marker = "\n\n...(truncated)...\n"
34
- # ponytail: header itself can exceed the limit if exc_value's str() is huge
35
- # (e.g. a validation error echoing a large payload) keep can go negative,
36
- # so clamp it and hard-truncate the final result as a backstop guarantee.
37
- keep = max(_MAX_MESSAGE_LENGTH - len(header) - len(marker), 0)
38
- truncated = f"{header}{marker}{body[-keep:]}" if keep else header
39
- return truncated[:_MAX_MESSAGE_LENGTH]
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
+ )
40
53
 
41
54
 
42
55
  # ponytail: fixed pattern list, not exhaustive — catches common
@@ -40,7 +40,9 @@ def _log_failed_delivery(text: str) -> None:
40
40
 
41
41
  def _send_telegram_message(bot_token: str, chat_id: int | str, text: str) -> bool:
42
42
  url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
43
- payload = json.dumps({"chat_id": chat_id, "text": text}).encode("utf-8")
43
+ payload = json.dumps(
44
+ {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
45
+ ).encode("utf-8")
44
46
  last_error: Exception | None = None
45
47
 
46
48
  for attempt in range(_MAX_ATTEMPTS):
File without changes