bugsradar 3.0.0__py3-none-any.whl
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.
- bugsradar/__init__.py +169 -0
- bugsradar/_client.py +448 -0
- bugsradar/_duplicates.py +58 -0
- bugsradar/_event.py +198 -0
- bugsradar/_hooks.py +61 -0
- bugsradar/_logging.py +139 -0
- bugsradar/_payload.py +47 -0
- bugsradar/_version.py +9 -0
- bugsradar/py.typed +0 -0
- bugsradar-3.0.0.dist-info/METADATA +78 -0
- bugsradar-3.0.0.dist-info/RECORD +13 -0
- bugsradar-3.0.0.dist-info/WHEEL +4 -0
- bugsradar-3.0.0.dist-info/licenses/LICENSE +21 -0
bugsradar/__init__.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BugsRadar: errors of your Python application in Telegram, Discord or Pushover.
|
|
3
|
+
|
|
4
|
+
import bugsradar
|
|
5
|
+
bugsradar.init(api_key=os.environ["BUGSRADAR_KEY"])
|
|
6
|
+
|
|
7
|
+
Documentation: https://bugsradar.com/docs/python/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import atexit
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from datetime import timedelta
|
|
17
|
+
from typing import Any, Mapping, Optional, Union
|
|
18
|
+
|
|
19
|
+
from . import _client, _hooks, _logging
|
|
20
|
+
from ._client import Client
|
|
21
|
+
from ._logging import LoggingHandler
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
|
|
24
|
+
__all__ = ["init", "send", "send_exception", "flush", "LoggingHandler", "__version__"]
|
|
25
|
+
|
|
26
|
+
Seconds = Union[float, timedelta]
|
|
27
|
+
|
|
28
|
+
_init_lock = threading.Lock()
|
|
29
|
+
_exit_hook_registered = False
|
|
30
|
+
_last_not_initialized_warning = 0.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def init(
|
|
34
|
+
api_key: str,
|
|
35
|
+
*,
|
|
36
|
+
environment: Optional[str] = None,
|
|
37
|
+
host: Optional[str] = None,
|
|
38
|
+
app_version: Optional[str] = None,
|
|
39
|
+
logging_level: Union[int, str, None] = logging.ERROR,
|
|
40
|
+
capture_uncaught: bool = True,
|
|
41
|
+
repeat_interval: Seconds = 5.0,
|
|
42
|
+
queue_capacity: int = 1000,
|
|
43
|
+
shutdown_timeout: Seconds = 5.0,
|
|
44
|
+
request_timeout: Seconds = 15.0,
|
|
45
|
+
api_url: Optional[str] = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Connects BugsRadar to logging and to uncaught exceptions; the application's logging setup stays as it is.
|
|
49
|
+
Call it once, at the start of the program; a second call replaces the settings of the first.
|
|
50
|
+
An empty api_key or an unknown logging_level raises ValueError.
|
|
51
|
+
"""
|
|
52
|
+
global _exit_hook_registered
|
|
53
|
+
|
|
54
|
+
level = None if logging_level is None else _logging.level_number(logging_level)
|
|
55
|
+
client = Client(
|
|
56
|
+
api_key,
|
|
57
|
+
environment=environment,
|
|
58
|
+
host=host,
|
|
59
|
+
app_version=app_version,
|
|
60
|
+
repeat_interval=repeat_interval,
|
|
61
|
+
queue_capacity=queue_capacity,
|
|
62
|
+
shutdown_timeout=shutdown_timeout,
|
|
63
|
+
request_timeout=request_timeout,
|
|
64
|
+
api_url=api_url,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
with _init_lock:
|
|
68
|
+
previous = _client.set_default(client)
|
|
69
|
+
if previous is not None:
|
|
70
|
+
# Whatever the previous client still holds goes out from its own thread.
|
|
71
|
+
previous.close()
|
|
72
|
+
|
|
73
|
+
_logging.install(level)
|
|
74
|
+
_hooks.install(capture_uncaught)
|
|
75
|
+
|
|
76
|
+
if not _exit_hook_registered:
|
|
77
|
+
_exit_hook_registered = True
|
|
78
|
+
atexit.register(_flush_at_exit)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def send(
|
|
82
|
+
*,
|
|
83
|
+
exception: Any = None,
|
|
84
|
+
level: Union[str, int, None] = None,
|
|
85
|
+
message_template: Optional[str] = None,
|
|
86
|
+
message: Optional[str] = None,
|
|
87
|
+
properties: Optional[Mapping[str, Any]] = None,
|
|
88
|
+
category: Optional[str] = None,
|
|
89
|
+
module: Optional[str] = None,
|
|
90
|
+
fingerprint: Optional[str] = None,
|
|
91
|
+
environment: Optional[str] = None,
|
|
92
|
+
host: Optional[str] = None,
|
|
93
|
+
app_version: Optional[str] = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
"""
|
|
96
|
+
Queues an event and returns at once. Never raises.
|
|
97
|
+
|
|
98
|
+
message_template is the text with placeholders, the same for every occurrence ("Order {order_id} failed"): it
|
|
99
|
+
groups repeats. fingerprint is your own grouping key. level: "error" by default, or "critical", "warning",
|
|
100
|
+
"information", "debug", "trace", or a logging level.
|
|
101
|
+
"""
|
|
102
|
+
client = _client.get_default()
|
|
103
|
+
if client is None:
|
|
104
|
+
_warn_not_initialized()
|
|
105
|
+
return
|
|
106
|
+
client._send(
|
|
107
|
+
"Direct",
|
|
108
|
+
exception=exception,
|
|
109
|
+
level=level,
|
|
110
|
+
message_template=message_template,
|
|
111
|
+
message=message,
|
|
112
|
+
properties=properties,
|
|
113
|
+
category=category,
|
|
114
|
+
module=module,
|
|
115
|
+
fingerprint=fingerprint,
|
|
116
|
+
environment=environment,
|
|
117
|
+
host=host,
|
|
118
|
+
app_version=app_version,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def send_exception(
|
|
123
|
+
error: BaseException,
|
|
124
|
+
*,
|
|
125
|
+
level: Union[str, int, None] = None,
|
|
126
|
+
message_template: Optional[str] = None,
|
|
127
|
+
message: Optional[str] = None,
|
|
128
|
+
properties: Optional[Mapping[str, Any]] = None,
|
|
129
|
+
category: Optional[str] = None,
|
|
130
|
+
module: Optional[str] = None,
|
|
131
|
+
fingerprint: Optional[str] = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Queues the exception, with its traceback and chain, and returns at once. Never raises."""
|
|
134
|
+
send(
|
|
135
|
+
exception=error,
|
|
136
|
+
level=level,
|
|
137
|
+
message_template=message_template,
|
|
138
|
+
message=message,
|
|
139
|
+
properties=properties,
|
|
140
|
+
category=category,
|
|
141
|
+
module=module,
|
|
142
|
+
fingerprint=fingerprint,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def flush(timeout: Optional[Seconds] = None) -> bool:
|
|
147
|
+
"""
|
|
148
|
+
Waits until everything queued so far has left, at most `timeout` seconds (shutdown_timeout by default).
|
|
149
|
+
True when the queue drained in time.
|
|
150
|
+
"""
|
|
151
|
+
client = _client.get_default()
|
|
152
|
+
if client is None:
|
|
153
|
+
return True
|
|
154
|
+
return client.flush(timeout)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _flush_at_exit() -> None:
|
|
158
|
+
client = _client.get_default()
|
|
159
|
+
if client is not None:
|
|
160
|
+
client.flush()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _warn_not_initialized() -> None:
|
|
164
|
+
global _last_not_initialized_warning
|
|
165
|
+
now = time.monotonic()
|
|
166
|
+
if _last_not_initialized_warning and now - _last_not_initialized_warning < _client.WARNING_INTERVAL:
|
|
167
|
+
return
|
|
168
|
+
_last_not_initialized_warning = now
|
|
169
|
+
_client.log.warning("BugsRadar: call bugsradar.init() first; the event was not sent")
|
bugsradar/_client.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""The client: a queue, a daemon thread and the rules of delivery shared with the .NET and Node.js packages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import ssl
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
import weakref
|
|
15
|
+
from collections import deque
|
|
16
|
+
from datetime import timedelta
|
|
17
|
+
from typing import Any, Deque, Dict, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
from ._duplicates import DuplicateFilter
|
|
20
|
+
from ._event import Event, capture, level_name, repeat_key, stringify_properties, text, utc
|
|
21
|
+
from ._payload import serialize
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
|
|
24
|
+
DEFAULT_API_URL = "https://api.bugsradar.com/api/v3/python/SendException"
|
|
25
|
+
# The server learns the package version from User-Agent.
|
|
26
|
+
USER_AGENT = "BugsRadar.Python/" + __version__
|
|
27
|
+
MAX_ATTEMPTS = 3
|
|
28
|
+
RETRY_DELAYS = (1.0, 5.0, 30.0)
|
|
29
|
+
DEFAULT_RETRY_AFTER = 5.0
|
|
30
|
+
WARNING_INTERVAL = 600.0
|
|
31
|
+
# How often the sending thread looks at the repeat windows when the queue is empty.
|
|
32
|
+
WINDOW_CHECK = 1.0
|
|
33
|
+
|
|
34
|
+
# The package's own diagnostics. Records of this logger are never sent: a delivery problem never becomes an event.
|
|
35
|
+
log = logging.getLogger("bugsradar")
|
|
36
|
+
|
|
37
|
+
_DELIVERED, _RETRY, _RATE_LIMITED, _DROP = range(4)
|
|
38
|
+
|
|
39
|
+
_clients: "weakref.WeakSet[Client]" = weakref.WeakSet()
|
|
40
|
+
_start_lock = threading.Lock()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Item:
|
|
44
|
+
"""An event to send, or the marker flush() puts behind everything queued so far."""
|
|
45
|
+
|
|
46
|
+
__slots__ = ("event", "count", "marker")
|
|
47
|
+
|
|
48
|
+
def __init__(self, event: Optional[Event], count: int, marker: Optional[threading.Event]) -> None:
|
|
49
|
+
self.event = event
|
|
50
|
+
self.count = count
|
|
51
|
+
self.marker = marker
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _Window:
|
|
55
|
+
__slots__ = ("opened_at", "count", "latest")
|
|
56
|
+
|
|
57
|
+
def __init__(self, opened_at: float, latest: Event) -> None:
|
|
58
|
+
self.opened_at = opened_at
|
|
59
|
+
self.count = 0
|
|
60
|
+
self.latest = latest
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Client:
|
|
64
|
+
"""
|
|
65
|
+
The one place events leave the process. Sending never waits for the network: the event is checked for
|
|
66
|
+
duplicates, folded with repeats of the same error and queued; a daemon thread posts to the server, respects 429
|
|
67
|
+
and retries transient failures. flush() waits for the queue, at most shutdown_timeout.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
api_key: str,
|
|
73
|
+
*,
|
|
74
|
+
environment: Optional[str] = None,
|
|
75
|
+
host: Optional[str] = None,
|
|
76
|
+
app_version: Optional[str] = None,
|
|
77
|
+
repeat_interval: float = 5.0,
|
|
78
|
+
queue_capacity: int = 1000,
|
|
79
|
+
shutdown_timeout: float = 5.0,
|
|
80
|
+
request_timeout: float = 15.0,
|
|
81
|
+
api_url: Optional[str] = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
if not isinstance(api_key, str) or not api_key.strip():
|
|
84
|
+
raise ValueError("BugsRadar: api_key is required")
|
|
85
|
+
|
|
86
|
+
self.api_key = api_key.strip()
|
|
87
|
+
self.api_url = text(api_url) or DEFAULT_API_URL
|
|
88
|
+
self.environment = text(environment)
|
|
89
|
+
self.host = _hostname() if host is None else text(host)
|
|
90
|
+
self.app_version = text(app_version)
|
|
91
|
+
self.repeat_interval = _number(repeat_interval, 5.0)
|
|
92
|
+
self.queue_capacity = int(_number(queue_capacity, 1000))
|
|
93
|
+
self.shutdown_timeout = _number(shutdown_timeout, 5.0)
|
|
94
|
+
self.request_timeout = _number(request_timeout, 15.0)
|
|
95
|
+
|
|
96
|
+
self._paused_until = 0.0
|
|
97
|
+
self._gone = False
|
|
98
|
+
self._deprecation_warned = False
|
|
99
|
+
self._last_warning: Dict[str, float] = {}
|
|
100
|
+
# Built by the sending thread before its first request; None is urllib's default.
|
|
101
|
+
self._tls: Optional[ssl.SSLContext] = None
|
|
102
|
+
self._tls_ready = False
|
|
103
|
+
self._reset()
|
|
104
|
+
_clients.add(self)
|
|
105
|
+
self._start()
|
|
106
|
+
|
|
107
|
+
def _reset(self) -> None:
|
|
108
|
+
"""Fresh queue, locks and windows: at creation, and in the child after a fork, where the thread is gone."""
|
|
109
|
+
self._condition = threading.Condition()
|
|
110
|
+
self._queue: Deque[_Item] = deque()
|
|
111
|
+
self._pending = 0
|
|
112
|
+
self._closing = False
|
|
113
|
+
self._windows: Dict[str, _Window] = {}
|
|
114
|
+
self._windows_lock = threading.Lock()
|
|
115
|
+
self._duplicates = DuplicateFilter()
|
|
116
|
+
self._thread: Optional[threading.Thread] = None
|
|
117
|
+
self._thread_pid: Optional[int] = None
|
|
118
|
+
|
|
119
|
+
def _send(
|
|
120
|
+
self,
|
|
121
|
+
source: str,
|
|
122
|
+
*,
|
|
123
|
+
exception: Any = None,
|
|
124
|
+
level: Any = None,
|
|
125
|
+
message_template: Any = None,
|
|
126
|
+
message: Any = None,
|
|
127
|
+
properties: Any = None,
|
|
128
|
+
category: Any = None,
|
|
129
|
+
environment: Any = None,
|
|
130
|
+
host: Any = None,
|
|
131
|
+
app_version: Any = None,
|
|
132
|
+
module: Any = None,
|
|
133
|
+
fingerprint: Any = None,
|
|
134
|
+
timestamp: Any = None,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Entry point of init's functions, the logging handler and the hooks. Never raises."""
|
|
137
|
+
try:
|
|
138
|
+
if self._gone or self._closing:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
level = level_name(level)
|
|
142
|
+
message_template = text(message_template)
|
|
143
|
+
message = text(message)
|
|
144
|
+
# Nothing to show: no exception, no text, no template.
|
|
145
|
+
if exception is None and message is None and message_template is None:
|
|
146
|
+
return
|
|
147
|
+
if self._duplicates.is_duplicate(exception, level, message_template, message, time.monotonic()):
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
self._enqueue(
|
|
151
|
+
Event(
|
|
152
|
+
timestamp=utc(timestamp),
|
|
153
|
+
level=level,
|
|
154
|
+
message_template=message_template,
|
|
155
|
+
message=message,
|
|
156
|
+
properties=stringify_properties(properties),
|
|
157
|
+
exceptions=capture(exception),
|
|
158
|
+
category=text(category),
|
|
159
|
+
environment=text(environment) or self.environment,
|
|
160
|
+
host=text(host) or self.host,
|
|
161
|
+
app_version=text(app_version) or self.app_version,
|
|
162
|
+
module=text(module),
|
|
163
|
+
fingerprint=text(fingerprint),
|
|
164
|
+
source=source,
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
except Exception:
|
|
168
|
+
self._warn("queue", "BugsRadar: failed to queue an event", exc_info=True)
|
|
169
|
+
|
|
170
|
+
def flush(self, timeout: Optional[float] = None) -> bool:
|
|
171
|
+
"""
|
|
172
|
+
Waits until everything queued so far has left, at most `timeout` seconds (shutdown_timeout by default).
|
|
173
|
+
True when the queue drained in time.
|
|
174
|
+
"""
|
|
175
|
+
limit = self.shutdown_timeout if timeout is None else _number(timeout, self.shutdown_timeout)
|
|
176
|
+
if threading.current_thread() is self._thread:
|
|
177
|
+
return False
|
|
178
|
+
self._flush_windows(True)
|
|
179
|
+
|
|
180
|
+
marker = threading.Event()
|
|
181
|
+
with self._condition:
|
|
182
|
+
self._queue.append(_Item(None, 0, marker))
|
|
183
|
+
self._condition.notify()
|
|
184
|
+
try:
|
|
185
|
+
self._start()
|
|
186
|
+
except Exception:
|
|
187
|
+
return False
|
|
188
|
+
return marker.wait(limit)
|
|
189
|
+
|
|
190
|
+
def close(self) -> None:
|
|
191
|
+
"""Takes no new events; the thread sends what is queued and ends. Does not wait."""
|
|
192
|
+
self._flush_windows(True)
|
|
193
|
+
with self._condition:
|
|
194
|
+
self._closing = True
|
|
195
|
+
self._condition.notify()
|
|
196
|
+
|
|
197
|
+
def _enqueue(self, event: Event) -> None:
|
|
198
|
+
key = repeat_key(event)
|
|
199
|
+
with self._windows_lock:
|
|
200
|
+
window = self._windows.get(key)
|
|
201
|
+
if window is not None:
|
|
202
|
+
# A repeat inside the interval only bumps the counter; it leaves with the next request of this window.
|
|
203
|
+
window.count += 1
|
|
204
|
+
window.latest = event
|
|
205
|
+
return
|
|
206
|
+
self._windows[key] = _Window(time.monotonic(), event)
|
|
207
|
+
|
|
208
|
+
self._post(event, 1)
|
|
209
|
+
|
|
210
|
+
def _flush_windows(self, force: bool) -> None:
|
|
211
|
+
"""Windows whose interval passed: repeats go out as one request with a count, idle windows close."""
|
|
212
|
+
try:
|
|
213
|
+
now = time.monotonic()
|
|
214
|
+
due = []
|
|
215
|
+
with self._windows_lock:
|
|
216
|
+
for key, window in list(self._windows.items()):
|
|
217
|
+
if not force and now - window.opened_at < self.repeat_interval:
|
|
218
|
+
continue
|
|
219
|
+
if window.count > 0:
|
|
220
|
+
due.append((window.latest, window.count))
|
|
221
|
+
window.count = 0
|
|
222
|
+
window.opened_at = now
|
|
223
|
+
else:
|
|
224
|
+
del self._windows[key]
|
|
225
|
+
|
|
226
|
+
for event, count in due:
|
|
227
|
+
self._post(event, count)
|
|
228
|
+
except Exception:
|
|
229
|
+
self._warn("windows", "BugsRadar: failed to flush repeat windows", exc_info=True)
|
|
230
|
+
|
|
231
|
+
def _post(self, event: Event, count: int) -> None:
|
|
232
|
+
with self._condition:
|
|
233
|
+
if self._closing:
|
|
234
|
+
return
|
|
235
|
+
full = self._pending >= self.queue_capacity
|
|
236
|
+
if not full:
|
|
237
|
+
self._pending += 1
|
|
238
|
+
self._queue.append(_Item(event, count, None))
|
|
239
|
+
self._condition.notify()
|
|
240
|
+
|
|
241
|
+
if full:
|
|
242
|
+
self._warn("queue-full", "BugsRadar: the outgoing queue is full (%d), events are being dropped", self.queue_capacity)
|
|
243
|
+
return
|
|
244
|
+
self._start()
|
|
245
|
+
|
|
246
|
+
def _start(self) -> None:
|
|
247
|
+
"""The sending thread of this process. After a fork the child has none: the first event starts it."""
|
|
248
|
+
pid = os.getpid()
|
|
249
|
+
if self._thread_pid == pid:
|
|
250
|
+
return
|
|
251
|
+
with _start_lock:
|
|
252
|
+
if self._thread_pid == pid:
|
|
253
|
+
return
|
|
254
|
+
thread = threading.Thread(target=self._run, name="bugsradar", daemon=True)
|
|
255
|
+
thread.start()
|
|
256
|
+
self._thread = thread
|
|
257
|
+
self._thread_pid = pid
|
|
258
|
+
|
|
259
|
+
def _run(self) -> None:
|
|
260
|
+
"""One thread sends the queue in order; markers of flush() are released when the thread reaches them."""
|
|
261
|
+
while True:
|
|
262
|
+
with self._condition:
|
|
263
|
+
if not self._queue and not self._closing:
|
|
264
|
+
self._condition.wait(WINDOW_CHECK)
|
|
265
|
+
item = self._queue.popleft() if self._queue else None
|
|
266
|
+
if item is not None and item.marker is None:
|
|
267
|
+
self._pending -= 1
|
|
268
|
+
if item is None and self._closing:
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
self._flush_windows(False)
|
|
272
|
+
if item is None:
|
|
273
|
+
continue
|
|
274
|
+
if item.marker is not None:
|
|
275
|
+
item.marker.set()
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
self._deliver(item)
|
|
280
|
+
except Exception:
|
|
281
|
+
self._warn("send", "BugsRadar: failed to send an event", exc_info=True)
|
|
282
|
+
|
|
283
|
+
def _deliver(self, item: _Item) -> None:
|
|
284
|
+
if self._gone or item.event is None:
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
body = serialize(item.event, item.count)
|
|
288
|
+
for attempt in range(MAX_ATTEMPTS):
|
|
289
|
+
pause = self._paused_until - time.monotonic()
|
|
290
|
+
if pause > 0:
|
|
291
|
+
time.sleep(pause)
|
|
292
|
+
|
|
293
|
+
outcome, retry_after = self._post_once(body)
|
|
294
|
+
if outcome in (_DELIVERED, _DROP):
|
|
295
|
+
return
|
|
296
|
+
if outcome == _RATE_LIMITED:
|
|
297
|
+
self._paused_until = time.monotonic() + retry_after
|
|
298
|
+
elif attempt + 1 < MAX_ATTEMPTS:
|
|
299
|
+
time.sleep(RETRY_DELAYS[attempt])
|
|
300
|
+
|
|
301
|
+
self._warn("dropped", "BugsRadar: an event was dropped after %d failed attempts", MAX_ATTEMPTS)
|
|
302
|
+
|
|
303
|
+
def _post_once(self, body: bytes) -> Tuple[int, float]:
|
|
304
|
+
request = urllib.request.Request(
|
|
305
|
+
self.api_url,
|
|
306
|
+
data=body,
|
|
307
|
+
method="POST",
|
|
308
|
+
headers={"Content-Type": "application/json", "X-Api-Key": self.api_key, "User-Agent": USER_AGENT},
|
|
309
|
+
)
|
|
310
|
+
if not self._tls_ready:
|
|
311
|
+
self._tls = _tls_context()
|
|
312
|
+
self._tls_ready = True
|
|
313
|
+
try:
|
|
314
|
+
with urllib.request.urlopen(request, timeout=self.request_timeout, context=self._tls) as response:
|
|
315
|
+
# The body is not needed; reading it frees the connection.
|
|
316
|
+
response.read()
|
|
317
|
+
if response.headers.get("Deprecation") is not None and not self._deprecation_warned:
|
|
318
|
+
self._deprecation_warned = True
|
|
319
|
+
log.warning("BugsRadar: this version of the bugsradar package is deprecated. Update it: pip install --upgrade bugsradar")
|
|
320
|
+
return _DELIVERED, 0.0
|
|
321
|
+
except urllib.error.HTTPError as error:
|
|
322
|
+
try:
|
|
323
|
+
error.read()
|
|
324
|
+
except Exception:
|
|
325
|
+
pass
|
|
326
|
+
finally:
|
|
327
|
+
error.close()
|
|
328
|
+
return self._interpret(error.code, error.headers)
|
|
329
|
+
except Exception:
|
|
330
|
+
# Network failure or timeout: worth a retry, not worth a warning every time.
|
|
331
|
+
log.debug("BugsRadar: request failed, will retry", exc_info=True)
|
|
332
|
+
return _RETRY, 0.0
|
|
333
|
+
|
|
334
|
+
def _interpret(self, status: int, headers: Any) -> Tuple[int, float]:
|
|
335
|
+
if status == 410:
|
|
336
|
+
# Nothing this version sends will ever be accepted: stop trying, tell the developer once.
|
|
337
|
+
if not self._gone:
|
|
338
|
+
self._gone = True
|
|
339
|
+
log.error("BugsRadar: this version of the bugsradar package is no longer supported, events are not delivered. Update it: pip install --upgrade bugsradar")
|
|
340
|
+
return _DROP, 0.0
|
|
341
|
+
if status == 401:
|
|
342
|
+
self._warn("auth", "BugsRadar: the server rejected the API key (401). Check api_key in bugsradar.init()")
|
|
343
|
+
return _DROP, 0.0
|
|
344
|
+
if status == 429:
|
|
345
|
+
return _RATE_LIMITED, _retry_after(headers.get("Retry-After") if headers is not None else None)
|
|
346
|
+
if status >= 500:
|
|
347
|
+
return _RETRY, 0.0
|
|
348
|
+
|
|
349
|
+
self._warn("rejected", "BugsRadar: the event was not accepted, the server returned %d", status)
|
|
350
|
+
return _DROP, 0.0
|
|
351
|
+
|
|
352
|
+
def _warn(self, kind: str, message: str, *args: Any, exc_info: bool = False) -> None:
|
|
353
|
+
"""Warnings of one kind at most every ten minutes: a broken network must not flood the application's log."""
|
|
354
|
+
try:
|
|
355
|
+
now = time.monotonic()
|
|
356
|
+
last = self._last_warning.get(kind)
|
|
357
|
+
if last is not None and now - last < WARNING_INTERVAL:
|
|
358
|
+
return
|
|
359
|
+
self._last_warning[kind] = now
|
|
360
|
+
log.warning(message, *args, exc_info=exc_info)
|
|
361
|
+
except Exception:
|
|
362
|
+
# Diagnostics must never break the application.
|
|
363
|
+
pass
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _tls_context() -> Optional[ssl.SSLContext]:
|
|
367
|
+
"""
|
|
368
|
+
None keeps urllib's default: the certificates the system trusts. On Windows that default also trusts the "CA"
|
|
369
|
+
store, where Windows keeps every intermediate certificate it has met, expired ones included. An expired
|
|
370
|
+
cross-signed ISRG Root X2 left there since 2025 breaks the current Let's Encrypt chain of api.bugsradar.com with
|
|
371
|
+
"certificate has expired", while .NET and browsers build the chain themselves and never notice. The server sends
|
|
372
|
+
all its intermediates, so on Windows the trusted roots are enough.
|
|
373
|
+
"""
|
|
374
|
+
if sys.platform != "win32":
|
|
375
|
+
return None
|
|
376
|
+
try:
|
|
377
|
+
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
378
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
379
|
+
context.set_alpn_protocols(["http/1.1"])
|
|
380
|
+
loaded = 0
|
|
381
|
+
for cert, encoding, trust in ssl.enum_certificates("ROOT"):
|
|
382
|
+
# trust is True for any purpose, or the set of purposes the root is trusted for.
|
|
383
|
+
if encoding != "x509_asn" or (trust is not True and ssl.Purpose.SERVER_AUTH.oid not in trust):
|
|
384
|
+
continue
|
|
385
|
+
try:
|
|
386
|
+
context.load_verify_locations(cadata=cert)
|
|
387
|
+
loaded += 1
|
|
388
|
+
except ssl.SSLError:
|
|
389
|
+
pass
|
|
390
|
+
# No roots at all is not a normal Windows: Python's default is the better guess then.
|
|
391
|
+
return context if loaded > 0 else None
|
|
392
|
+
except Exception:
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _hostname() -> Optional[str]:
|
|
397
|
+
try:
|
|
398
|
+
return text(socket.gethostname())
|
|
399
|
+
except Exception:
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _number(value: Any, fallback: float) -> float:
|
|
404
|
+
if isinstance(value, timedelta):
|
|
405
|
+
value = value.total_seconds()
|
|
406
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value or value < 0:
|
|
407
|
+
return fallback
|
|
408
|
+
return float(value)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _retry_after(value: Any) -> float:
|
|
412
|
+
try:
|
|
413
|
+
seconds = float(value)
|
|
414
|
+
except (TypeError, ValueError):
|
|
415
|
+
return DEFAULT_RETRY_AFTER
|
|
416
|
+
if seconds != seconds or seconds <= 0:
|
|
417
|
+
return DEFAULT_RETRY_AFTER
|
|
418
|
+
return max(1.0, seconds)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
_default: Optional[Client] = None
|
|
422
|
+
_default_lock = threading.Lock()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def get_default() -> Optional[Client]:
|
|
426
|
+
"""The client created by bugsradar.init()."""
|
|
427
|
+
return _default
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def set_default(client: Optional[Client]) -> Optional[Client]:
|
|
431
|
+
"""Makes `client` the default one and returns the previous."""
|
|
432
|
+
global _default
|
|
433
|
+
with _default_lock:
|
|
434
|
+
previous, _default = _default, client
|
|
435
|
+
return previous
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _after_fork_in_child() -> None:
|
|
439
|
+
# A lock held by another thread at the moment of the fork would stay locked in the child forever.
|
|
440
|
+
global _start_lock, _default_lock
|
|
441
|
+
_start_lock = threading.Lock()
|
|
442
|
+
_default_lock = threading.Lock()
|
|
443
|
+
for client in list(_clients):
|
|
444
|
+
client._reset()
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
if hasattr(os, "register_at_fork"):
|
|
448
|
+
os.register_at_fork(after_in_child=_after_fork_in_child)
|
bugsradar/_duplicates.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The same failure reaching the package twice is sent once."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
TEXT_WINDOW = 2.0
|
|
9
|
+
MAX_TEXT_ENTRIES = 1000
|
|
10
|
+
_MARK = "_bugsradar_seen"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DuplicateFilter:
|
|
14
|
+
"""
|
|
15
|
+
The same failure often reaches the package twice: logged with logger.exception and also reported by hand.
|
|
16
|
+
An exception object is marked when first seen, so the mark lives and dies with it and holds no memory;
|
|
17
|
+
events without an exception are remembered for two seconds by level, template and text.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
self._texts: Dict[str, float] = {}
|
|
23
|
+
|
|
24
|
+
def is_duplicate(self, exception: Any, level: str, message_template: Optional[str], message: Optional[str], now: float) -> bool:
|
|
25
|
+
if isinstance(exception, BaseException):
|
|
26
|
+
if getattr(exception, _MARK, False) is True:
|
|
27
|
+
return True
|
|
28
|
+
try:
|
|
29
|
+
setattr(exception, _MARK, True)
|
|
30
|
+
except Exception:
|
|
31
|
+
# An exception that takes no attributes cannot be recognised again: it is never a duplicate.
|
|
32
|
+
pass
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
thrown = "" if exception is None else _safe_str(exception)
|
|
36
|
+
key = f"{level}|{message_template or ''}|{message or ''}|{thrown}"
|
|
37
|
+
with self._lock:
|
|
38
|
+
seen_at = self._texts.get(key)
|
|
39
|
+
if seen_at is not None and now - seen_at < TEXT_WINDOW:
|
|
40
|
+
return True
|
|
41
|
+
if len(self._texts) >= MAX_TEXT_ENTRIES:
|
|
42
|
+
self._sweep(now)
|
|
43
|
+
self._texts[key] = now
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def _sweep(self, now: float) -> None:
|
|
47
|
+
for key in [key for key, seen_at in self._texts.items() if now - seen_at >= TEXT_WINDOW]:
|
|
48
|
+
del self._texts[key]
|
|
49
|
+
# A flood of distinct texts within two seconds: forget everything rather than grow without bound.
|
|
50
|
+
if len(self._texts) >= MAX_TEXT_ENTRIES:
|
|
51
|
+
self._texts.clear()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _safe_str(value: Any) -> str:
|
|
55
|
+
try:
|
|
56
|
+
return str(value)
|
|
57
|
+
except Exception:
|
|
58
|
+
return type(value).__name__
|
bugsradar/_event.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Events are built in the caller's thread. The exception becomes plain data at once, frames without source lines,
|
|
3
|
+
so the queue never keeps frames and their locals alive; the source lines are read later, in the sending thread.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import traceback
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
MAX_EXCEPTIONS = 10
|
|
16
|
+
MAX_FRAMES = 100
|
|
17
|
+
MAX_PROPERTIES = 50
|
|
18
|
+
|
|
19
|
+
# Level names the server knows. Anything else becomes Error there too.
|
|
20
|
+
_LEVEL_NAMES = {
|
|
21
|
+
"trace": "Trace",
|
|
22
|
+
"debug": "Debug",
|
|
23
|
+
"info": "Information",
|
|
24
|
+
"information": "Information",
|
|
25
|
+
"warn": "Warning",
|
|
26
|
+
"warning": "Warning",
|
|
27
|
+
"error": "Error",
|
|
28
|
+
"exception": "Error",
|
|
29
|
+
"fatal": "Critical",
|
|
30
|
+
"critical": "Critical",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class CapturedException:
|
|
36
|
+
type: str
|
|
37
|
+
message: Optional[str]
|
|
38
|
+
frames: Optional[traceback.StackSummary]
|
|
39
|
+
|
|
40
|
+
def top_frame(self) -> str:
|
|
41
|
+
if not self.frames:
|
|
42
|
+
return ""
|
|
43
|
+
frame = self.frames[-1]
|
|
44
|
+
return f"{frame.filename}:{frame.lineno}:{frame.name}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Event:
|
|
49
|
+
timestamp: datetime
|
|
50
|
+
level: str
|
|
51
|
+
message_template: Optional[str]
|
|
52
|
+
message: Optional[str]
|
|
53
|
+
properties: Optional[Dict[str, str]]
|
|
54
|
+
exceptions: Optional[List[CapturedException]]
|
|
55
|
+
category: Optional[str]
|
|
56
|
+
environment: Optional[str]
|
|
57
|
+
host: Optional[str]
|
|
58
|
+
app_version: Optional[str]
|
|
59
|
+
module: Optional[str]
|
|
60
|
+
fingerprint: Optional[str]
|
|
61
|
+
source: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def capture(error: Any) -> Optional[List[CapturedException]]:
|
|
65
|
+
"""The exception and its chain (raise ... from, or an exception raised while handling another), outermost first."""
|
|
66
|
+
if error is None:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
result: List[CapturedException] = []
|
|
70
|
+
seen = set()
|
|
71
|
+
current = error
|
|
72
|
+
while current is not None and len(result) < MAX_EXCEPTIONS and id(current) not in seen:
|
|
73
|
+
seen.add(id(current))
|
|
74
|
+
result.append(_capture_one(current))
|
|
75
|
+
current = _next(current)
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _capture_one(error: Any) -> CapturedException:
|
|
80
|
+
if not isinstance(error, BaseException):
|
|
81
|
+
# send(exception="text"): no class of its own and no traceback.
|
|
82
|
+
return CapturedException(type(error).__name__, _safe_str(error), None)
|
|
83
|
+
|
|
84
|
+
# The frames closest to the error; a RecursionError alone would bring a thousand.
|
|
85
|
+
frames = list(traceback.walk_tb(error.__traceback__))[-MAX_FRAMES:]
|
|
86
|
+
summary = traceback.StackSummary.extract(iter(frames), lookup_lines=False) if frames else None
|
|
87
|
+
message = _safe_str(error)
|
|
88
|
+
return CapturedException(type_name(error), message or None, summary)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _next(error: Any) -> Any:
|
|
92
|
+
if not isinstance(error, BaseException):
|
|
93
|
+
return None
|
|
94
|
+
if error.__cause__ is not None:
|
|
95
|
+
return error.__cause__
|
|
96
|
+
# raise ... from None hides the exception that was being handled.
|
|
97
|
+
if error.__suppress_context__:
|
|
98
|
+
return None
|
|
99
|
+
return error.__context__
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def repeat_key(event: Event) -> str:
|
|
103
|
+
"""
|
|
104
|
+
Key for folding repeats inside the package: exception type, top frame, template or text. Coarser than the
|
|
105
|
+
server's fingerprint on purpose: it only decides which events travel together in one request.
|
|
106
|
+
"""
|
|
107
|
+
if event.fingerprint:
|
|
108
|
+
return "f|" + event.fingerprint
|
|
109
|
+
if event.exceptions:
|
|
110
|
+
first = event.exceptions[0]
|
|
111
|
+
# Without a traceback, the text tells errors of one type apart.
|
|
112
|
+
where = first.top_frame() or (first.message or "")
|
|
113
|
+
return f"e|{first.type}|{where}|{event.message_template or ''}"
|
|
114
|
+
return f"m|{event.level}|{event.message_template or event.message or ''}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def type_name(error: BaseException) -> str:
|
|
118
|
+
"""ValueError for built-ins and __main__, myapp.errors.OrderError for the rest."""
|
|
119
|
+
cls = type(error)
|
|
120
|
+
name = getattr(cls, "__qualname__", None) or cls.__name__
|
|
121
|
+
module = getattr(cls, "__module__", None)
|
|
122
|
+
if not module or module in ("builtins", "__main__"):
|
|
123
|
+
return name
|
|
124
|
+
return f"{module}.{name}"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def level_name(level: Any) -> str:
|
|
128
|
+
"""'warning' or logging.WARNING → 'Warning'; empty or unknown → 'Error'."""
|
|
129
|
+
if isinstance(level, bool):
|
|
130
|
+
return "Error"
|
|
131
|
+
if isinstance(level, int):
|
|
132
|
+
if level >= logging.CRITICAL:
|
|
133
|
+
return "Critical"
|
|
134
|
+
if level >= logging.ERROR:
|
|
135
|
+
return "Error"
|
|
136
|
+
if level >= logging.WARNING:
|
|
137
|
+
return "Warning"
|
|
138
|
+
if level >= logging.INFO:
|
|
139
|
+
return "Information"
|
|
140
|
+
if level >= logging.DEBUG:
|
|
141
|
+
return "Debug"
|
|
142
|
+
return "Trace"
|
|
143
|
+
if isinstance(level, str):
|
|
144
|
+
return _LEVEL_NAMES.get(level.strip().lower(), "Error")
|
|
145
|
+
return "Error"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def stringify_properties(values: Any) -> Optional[Dict[str, str]]:
|
|
149
|
+
"""Values travel as strings: dicts and lists as JSON, exceptions as "Type: message"."""
|
|
150
|
+
if not values:
|
|
151
|
+
return None
|
|
152
|
+
try:
|
|
153
|
+
items = list(values.items())
|
|
154
|
+
except AttributeError:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
result: Dict[str, str] = {}
|
|
158
|
+
for key, value in items:
|
|
159
|
+
if len(result) == MAX_PROPERTIES:
|
|
160
|
+
break
|
|
161
|
+
result[_safe_str(key)] = _stringify(value)
|
|
162
|
+
return result or None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def text(value: Any) -> Optional[str]:
|
|
166
|
+
"""None, an empty string and a string of spaces are the same as no value."""
|
|
167
|
+
if value is None:
|
|
168
|
+
return None
|
|
169
|
+
result = value if isinstance(value, str) else _safe_str(value)
|
|
170
|
+
result = result.strip()
|
|
171
|
+
return result or None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def utc(value: Any) -> datetime:
|
|
175
|
+
if isinstance(value, datetime):
|
|
176
|
+
# A naive datetime is taken as local time, as datetime itself does.
|
|
177
|
+
return value.astimezone(timezone.utc)
|
|
178
|
+
return datetime.now(timezone.utc)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _stringify(value: Any) -> str:
|
|
182
|
+
if isinstance(value, str):
|
|
183
|
+
return value
|
|
184
|
+
if isinstance(value, BaseException):
|
|
185
|
+
return f"{type_name(value)}: {_safe_str(value)}"
|
|
186
|
+
if isinstance(value, (dict, list, tuple)):
|
|
187
|
+
try:
|
|
188
|
+
return json.dumps(value, ensure_ascii=False, default=str)
|
|
189
|
+
except Exception:
|
|
190
|
+
pass
|
|
191
|
+
return _safe_str(value)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _safe_str(value: Any) -> str:
|
|
195
|
+
try:
|
|
196
|
+
return str(value)
|
|
197
|
+
except Exception:
|
|
198
|
+
return f"<{type(value).__name__}>"
|
bugsradar/_hooks.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Uncaught exceptions: sys.excepthook for the program, threading.excepthook for threads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from . import _client
|
|
10
|
+
|
|
11
|
+
_lock = threading.Lock()
|
|
12
|
+
_installed = False
|
|
13
|
+
_enabled = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def install(enabled: bool) -> None:
|
|
17
|
+
"""
|
|
18
|
+
The hooks are set once per process and report to whichever client init() created last; each calls the hook it
|
|
19
|
+
replaced, so the traceback is still printed and other tools keep working. init(capture_uncaught=False) turns
|
|
20
|
+
reporting off without taking the hooks down.
|
|
21
|
+
"""
|
|
22
|
+
global _installed, _enabled
|
|
23
|
+
with _lock:
|
|
24
|
+
_enabled = enabled
|
|
25
|
+
if _installed or not enabled:
|
|
26
|
+
return
|
|
27
|
+
_installed = True
|
|
28
|
+
|
|
29
|
+
previous_excepthook = sys.excepthook
|
|
30
|
+
previous_threading_excepthook = threading.excepthook
|
|
31
|
+
|
|
32
|
+
def excepthook(exc_type: Any, exc_value: Any, exc_traceback: Any) -> None:
|
|
33
|
+
# Ctrl+C is the user's decision, not an error.
|
|
34
|
+
if not (isinstance(exc_type, type) and issubclass(exc_type, KeyboardInterrupt)):
|
|
35
|
+
_report(exc_value, "critical", None)
|
|
36
|
+
previous_excepthook(exc_type, exc_value, exc_traceback)
|
|
37
|
+
|
|
38
|
+
def threading_excepthook(args: Any) -> None:
|
|
39
|
+
# SystemExit ends a thread quietly; Python's own hook ignores it too.
|
|
40
|
+
if not (isinstance(args.exc_type, type) and issubclass(args.exc_type, SystemExit)):
|
|
41
|
+
thread = args.thread
|
|
42
|
+
_report(args.exc_value, "error", {"thread": thread.name} if thread is not None else None)
|
|
43
|
+
previous_threading_excepthook(args)
|
|
44
|
+
|
|
45
|
+
sys.excepthook = excepthook
|
|
46
|
+
threading.excepthook = threading_excepthook
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _report(error: Any, level: str, properties: Optional[Dict[str, Any]]) -> None:
|
|
50
|
+
"""Reports and waits up to shutdown_timeout for the report to leave: the program or the thread is ending."""
|
|
51
|
+
if not _enabled:
|
|
52
|
+
return
|
|
53
|
+
try:
|
|
54
|
+
client = _client.get_default()
|
|
55
|
+
if client is None:
|
|
56
|
+
return
|
|
57
|
+
client._send("Uncaught", exception=error, level=level, properties=properties)
|
|
58
|
+
client.flush()
|
|
59
|
+
except Exception:
|
|
60
|
+
# A hook must never make the crash worse.
|
|
61
|
+
pass
|
bugsradar/_logging.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Records of the standard logging module → BugsRadar events."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Dict, Optional, Union
|
|
9
|
+
|
|
10
|
+
from . import _client
|
|
11
|
+
|
|
12
|
+
# Attributes every LogRecord has; the rest came from extra={...}.
|
|
13
|
+
_RECORD_FIELDS = frozenset(vars(logging.LogRecord("", logging.ERROR, "", 0, "", (), None))) | {"message", "asctime"}
|
|
14
|
+
# Set on a record once it is sent: the hook and a LoggingHandler never send one record twice.
|
|
15
|
+
_SENT = "_bugsradar_sent"
|
|
16
|
+
|
|
17
|
+
_lock = threading.Lock()
|
|
18
|
+
_level: Optional[int] = None
|
|
19
|
+
_patched = False
|
|
20
|
+
_sending = threading.local()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LoggingHandler(logging.Handler):
|
|
24
|
+
"""
|
|
25
|
+
Sends log records to BugsRadar through the client of bugsradar.init(). Needed only with
|
|
26
|
+
init(logging_level=None): add it to the loggers you choose, in code or in a dictConfig.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, level: Union[int, str] = logging.ERROR) -> None:
|
|
30
|
+
super().__init__(level)
|
|
31
|
+
|
|
32
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
33
|
+
try:
|
|
34
|
+
_send(record)
|
|
35
|
+
except Exception:
|
|
36
|
+
self.handleError(record)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def install(level: Optional[int]) -> None:
|
|
40
|
+
"""
|
|
41
|
+
Records of every logger at `level` and above go to BugsRadar, whatever handlers the application's logging setup
|
|
42
|
+
has. Logger.callHandlers is wrapped once per process, as Sentry does, and no handler is added anywhere, so
|
|
43
|
+
logging.basicConfig and Python's last-resort output to stderr work as before. None turns it off.
|
|
44
|
+
"""
|
|
45
|
+
global _level, _patched
|
|
46
|
+
with _lock:
|
|
47
|
+
_level = level
|
|
48
|
+
if _patched or level is None:
|
|
49
|
+
return
|
|
50
|
+
_patched = True
|
|
51
|
+
original = logging.Logger.callHandlers
|
|
52
|
+
|
|
53
|
+
def call_handlers(self: logging.Logger, record: logging.LogRecord) -> Any:
|
|
54
|
+
try:
|
|
55
|
+
return original(self, record)
|
|
56
|
+
finally:
|
|
57
|
+
# After the application's own handlers: its log output comes first.
|
|
58
|
+
_hook(record)
|
|
59
|
+
|
|
60
|
+
logging.Logger.callHandlers = call_handlers # type: ignore[method-assign]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def level_number(level: Union[int, str]) -> int:
|
|
64
|
+
"""logging.WARNING or "WARNING" → 30."""
|
|
65
|
+
if isinstance(level, int) and not isinstance(level, bool):
|
|
66
|
+
return level
|
|
67
|
+
if isinstance(level, str):
|
|
68
|
+
number = logging.getLevelName(level.strip().upper())
|
|
69
|
+
if isinstance(number, int):
|
|
70
|
+
return number
|
|
71
|
+
raise ValueError(f"BugsRadar: unknown logging_level {level!r}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _hook(record: logging.LogRecord) -> None:
|
|
75
|
+
level = _level
|
|
76
|
+
if level is None or record.levelno < level:
|
|
77
|
+
return
|
|
78
|
+
try:
|
|
79
|
+
_send(record)
|
|
80
|
+
except Exception:
|
|
81
|
+
# Reporting must never break logging.
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _send(record: logging.LogRecord) -> None:
|
|
86
|
+
# The package's own warnings are never sent: a broken network must not feed on itself.
|
|
87
|
+
if record.name == "bugsradar" or record.name.startswith("bugsradar."):
|
|
88
|
+
return
|
|
89
|
+
# Logging done while a record is being sent (a __str__ of a value in extra, say) is not sent.
|
|
90
|
+
if getattr(record, _SENT, False) or getattr(_sending, "active", False):
|
|
91
|
+
return
|
|
92
|
+
client = _client.get_default()
|
|
93
|
+
if client is None:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
setattr(record, _SENT, True)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
_sending.active = True
|
|
102
|
+
try:
|
|
103
|
+
exception = _exception(record)
|
|
104
|
+
client._send(
|
|
105
|
+
"logging",
|
|
106
|
+
exception=exception,
|
|
107
|
+
level=record.levelno,
|
|
108
|
+
# "Order %s failed": failures of different orders are one error with a count.
|
|
109
|
+
message_template=str(record.msg) if record.args else None,
|
|
110
|
+
# logger.error(error) makes the exception itself the message: it is already in the exception.
|
|
111
|
+
message=None if exception is not None and exception is record.msg else _message(record),
|
|
112
|
+
properties=_extra(record),
|
|
113
|
+
category=record.name,
|
|
114
|
+
timestamp=datetime.fromtimestamp(record.created, timezone.utc),
|
|
115
|
+
)
|
|
116
|
+
finally:
|
|
117
|
+
_sending.active = False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _exception(record: logging.LogRecord) -> Optional[BaseException]:
|
|
121
|
+
# logger.exception and exc_info=True bring the exception being handled.
|
|
122
|
+
if record.exc_info and isinstance(record.exc_info[1], BaseException):
|
|
123
|
+
return record.exc_info[1]
|
|
124
|
+
if isinstance(record.msg, BaseException):
|
|
125
|
+
return record.msg
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _message(record: logging.LogRecord) -> str:
|
|
130
|
+
try:
|
|
131
|
+
return record.getMessage()
|
|
132
|
+
except Exception:
|
|
133
|
+
# Arguments that do not fit the format string: the format string itself is better than nothing.
|
|
134
|
+
return str(record.msg)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _extra(record: logging.LogRecord) -> Optional[Dict[str, Any]]:
|
|
138
|
+
extra = {key: value for key, value in vars(record).items() if key not in _RECORD_FIELDS and not key.startswith("_")}
|
|
139
|
+
return extra or None
|
bugsradar/_payload.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Body of api/v3/python/SendException, built in the sending thread."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
|
|
9
|
+
from ._event import CapturedException, Event
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def serialize(event: Event, count: int) -> bytes:
|
|
13
|
+
"""Fields without a value are left out."""
|
|
14
|
+
body = {
|
|
15
|
+
"timestamp": _iso(event.timestamp),
|
|
16
|
+
"level": event.level,
|
|
17
|
+
"messageTemplate": event.message_template,
|
|
18
|
+
"message": event.message,
|
|
19
|
+
"properties": event.properties,
|
|
20
|
+
"exceptions": [_exception(e) for e in event.exceptions] if event.exceptions else None,
|
|
21
|
+
"category": event.category,
|
|
22
|
+
"environment": event.environment,
|
|
23
|
+
"host": event.host,
|
|
24
|
+
"appVersion": event.app_version,
|
|
25
|
+
"module": event.module,
|
|
26
|
+
"source": event.source,
|
|
27
|
+
"fingerprint": event.fingerprint,
|
|
28
|
+
"count": count if count > 1 else 1,
|
|
29
|
+
}
|
|
30
|
+
text = json.dumps({key: value for key, value in body.items() if value is not None}, ensure_ascii=False, separators=(",", ":"))
|
|
31
|
+
# A lone surrogate (a file name decoded with surrogateescape, say) has no UTF-8 form: it becomes "?".
|
|
32
|
+
return text.encode("utf-8", errors="replace")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _exception(captured: CapturedException) -> Dict[str, Any]:
|
|
36
|
+
result: Dict[str, Any] = {"type": captured.type}
|
|
37
|
+
if captured.message is not None:
|
|
38
|
+
result["message"] = captured.message
|
|
39
|
+
if captured.frames:
|
|
40
|
+
# traceback.format_tb lines, from the outer call to the error; source lines are read here.
|
|
41
|
+
result["traceback"] = "".join(captured.frames.format())
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _iso(value: datetime) -> str:
|
|
46
|
+
moment = value.astimezone(timezone.utc)
|
|
47
|
+
return moment.strftime("%Y-%m-%dT%H:%M:%S.") + f"{moment.microsecond // 1000:03d}Z"
|
bugsradar/_version.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The package version comes from its metadata, so pyproject.toml is the only place to change it."""
|
|
2
|
+
|
|
3
|
+
from importlib import metadata
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = metadata.version("bugsradar")
|
|
7
|
+
except metadata.PackageNotFoundError:
|
|
8
|
+
# Running from a source checkout that was never installed.
|
|
9
|
+
__version__ = "0.0.0"
|
bugsradar/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bugsradar
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: Errors of your Python application in Telegram, Discord or Pushover: logging, uncaught exceptions and direct calls in one package.
|
|
5
|
+
Project-URL: Homepage, https://bugsradar.com/docs/python/
|
|
6
|
+
Author: Bistriy Sp. z o.o.
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: alerts,bugsradar,discord,error,exception,logging,pushover,telegram
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Topic :: System :: Logging
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# BugsRadar for Python
|
|
20
|
+
|
|
21
|
+
Errors of your Python application in Telegram, Discord or Pushover: the standard `logging` module, uncaught exceptions and direct calls in one package. Python 3.9 and later, no dependencies outside the standard library.
|
|
22
|
+
|
|
23
|
+
Full documentation: https://bugsradar.com/docs/python/
|
|
24
|
+
|
|
25
|
+
You need a BugsRadar project with a channel and the project's API key: create them at https://app.bugsradar.com/.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
pip install bugsradar
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Set it up
|
|
34
|
+
|
|
35
|
+
Once, at the start of the program:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import os
|
|
39
|
+
import bugsradar
|
|
40
|
+
|
|
41
|
+
bugsradar.init(
|
|
42
|
+
api_key=os.environ["BUGSRADAR_KEY"],
|
|
43
|
+
environment="Production", # optional
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
> **The API key is secret: use it only in code that runs on your servers.** Never put it in a desktop app you ship to others (PyInstaller, PySide or PyQt builds) or in a public repository: anyone can take the key out of them. For such apps, public keys are on the way: https://bugsradar.com/docs/#public-keys
|
|
48
|
+
|
|
49
|
+
From here on, records at `ERROR` and above from every logger and uncaught exceptions go to BugsRadar. `init` adds no handlers: your logging setup works as before. Django, Flask and Uvicorn log unhandled exceptions of requests, so `init` covers them too.
|
|
50
|
+
|
|
51
|
+
## logging
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
logger.exception("Order %s failed", order_id)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The format string becomes the message template, so failures of different orders are one error with a count.
|
|
58
|
+
|
|
59
|
+
## Direct calls
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
try:
|
|
63
|
+
create_order(order_id)
|
|
64
|
+
except Exception as error:
|
|
65
|
+
bugsradar.send_exception(error, module="Orders")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`send` and `send_exception` queue the event and return at once; nothing raises.
|
|
69
|
+
|
|
70
|
+
## Before the program exits
|
|
71
|
+
|
|
72
|
+
`init` registers an `atexit` hook that waits up to `shutdown_timeout` (5 s) for queued reports. In a short script you can also wait yourself:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
bugsradar.flush()
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Questions: support@bistriy.com
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
bugsradar/__init__.py,sha256=NFE8cR0heYa6BA4ua7aOlEAEtzFXutOcFNE1GE-YmAg,5080
|
|
2
|
+
bugsradar/_client.py,sha256=Be9UGEhNaLfF4RhDmIFrFCZXN0JsmA2cgpDFqW4geYc,17163
|
|
3
|
+
bugsradar/_duplicates.py,sha256=apKvOCbTPsOua8CyoLuqzxYH7qN9Llry3q7f2mbVhbE,2151
|
|
4
|
+
bugsradar/_event.py,sha256=_2cyT2ZiYSxXpdZ7f7f8oKE_A6ZhRIh_sV_jIwhViao,6238
|
|
5
|
+
bugsradar/_hooks.py,sha256=XfIcO5h5bs6mmA9EpaL9Tq_JmEA4xcoIR6YPBV8djFo,2268
|
|
6
|
+
bugsradar/_logging.py,sha256=MbOZX-shOIj5UKw3ul5f2hiM3frImQ3gCT607Q_ohpw,4893
|
|
7
|
+
bugsradar/_payload.py,sha256=8wjF0t3n7D1ma_15M3apENJLRC2nhn2q8Whtd2aZnE4,1805
|
|
8
|
+
bugsradar/_version.py,sha256=jMDiRdtqbg0Es8WA64pnNLvYhASMPk6yqRqktrEMo9o,314
|
|
9
|
+
bugsradar/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
bugsradar-3.0.0.dist-info/METADATA,sha256=4SDG-9TWpm4rLl7Ci9zkwuS6pTyhR27U1_cPV721YYs,2587
|
|
11
|
+
bugsradar-3.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
12
|
+
bugsradar-3.0.0.dist-info/licenses/LICENSE,sha256=i-sSF-C-tqqEgbASCdz2eMVgDg7fjlnTyoUZ9VSfsRM,1075
|
|
13
|
+
bugsradar-3.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bistriy Sp. z o.o.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|