sauron-sdk 1.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.
- sauron/__init__.py +326 -0
- sauron/_autocapture.py +82 -0
- sauron/_client.py +420 -0
- sauron/_dsn.py +114 -0
- sauron/_gzip.py +28 -0
- sauron/_queue.py +194 -0
- sauron/_scope.py +251 -0
- sauron/_stacktrace.py +87 -0
- sauron/_transport.py +358 -0
- sauron/py.typed +0 -0
- sauron_sdk-1.0.0.dist-info/METADATA +135 -0
- sauron_sdk-1.0.0.dist-info/RECORD +15 -0
- sauron_sdk-1.0.0.dist-info/WHEEL +5 -0
- sauron_sdk-1.0.0.dist-info/licenses/LICENSE +661 -0
- sauron_sdk-1.0.0.dist-info/top_level.txt +1 -0
sauron/__init__.py
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""Sauron server-side Python SDK.
|
|
2
|
+
|
|
3
|
+
Public API::
|
|
4
|
+
|
|
5
|
+
import sauron
|
|
6
|
+
sauron.init(dsn="https://pk@host/1")
|
|
7
|
+
sauron.track("event", distinct_id="u_1", properties={...})
|
|
8
|
+
sauron.identify("u_1", traits={...})
|
|
9
|
+
sauron.capture_exception() # reads the active exception
|
|
10
|
+
sauron.capture_message("hello")
|
|
11
|
+
sauron.flush()
|
|
12
|
+
sauron.close()
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import atexit
|
|
18
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Sequence
|
|
19
|
+
|
|
20
|
+
from ._client import SDK_NAME, SDK_VERSION, Client
|
|
21
|
+
from ._dsn import Dsn, DsnError, parse_dsn
|
|
22
|
+
from ._scope import (
|
|
23
|
+
Scope,
|
|
24
|
+
build_breadcrumb,
|
|
25
|
+
configure_scope,
|
|
26
|
+
get_current_scope,
|
|
27
|
+
get_global_scope,
|
|
28
|
+
pop_scope,
|
|
29
|
+
push_scope,
|
|
30
|
+
scope,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"init",
|
|
35
|
+
"track",
|
|
36
|
+
"track_transaction",
|
|
37
|
+
"capture_exception",
|
|
38
|
+
"capture_message",
|
|
39
|
+
"identify",
|
|
40
|
+
"add_breadcrumb",
|
|
41
|
+
"set_user",
|
|
42
|
+
"set_tag",
|
|
43
|
+
"set_tags",
|
|
44
|
+
"set_context",
|
|
45
|
+
"set_extra",
|
|
46
|
+
"configure_scope",
|
|
47
|
+
"scope",
|
|
48
|
+
"push_scope",
|
|
49
|
+
"pop_scope",
|
|
50
|
+
"get_current_scope",
|
|
51
|
+
"get_global_scope",
|
|
52
|
+
"flush",
|
|
53
|
+
"close",
|
|
54
|
+
"get_client",
|
|
55
|
+
"Client",
|
|
56
|
+
"Scope",
|
|
57
|
+
"Dsn",
|
|
58
|
+
"DsnError",
|
|
59
|
+
"parse_dsn",
|
|
60
|
+
"SDK_NAME",
|
|
61
|
+
"SDK_VERSION",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
# The process-wide active client. ``None`` until ``init`` succeeds.
|
|
65
|
+
_client: Optional[Client] = None
|
|
66
|
+
|
|
67
|
+
# Register the atexit flush exactly once per process (idempotent across inits).
|
|
68
|
+
_atexit_registered = False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _atexit_flush() -> None:
|
|
72
|
+
"""Flush + close the active client at interpreter shutdown (best-effort)."""
|
|
73
|
+
if _client is not None:
|
|
74
|
+
try:
|
|
75
|
+
_client.close()
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def init(
|
|
81
|
+
dsn: Optional[str] = None,
|
|
82
|
+
*,
|
|
83
|
+
environment: str = "production",
|
|
84
|
+
release: Optional[str] = None,
|
|
85
|
+
sample_rate: float = 1.0,
|
|
86
|
+
flush_interval: float = 5.0,
|
|
87
|
+
max_batch: int = 30,
|
|
88
|
+
max_breadcrumbs: int = 100,
|
|
89
|
+
tags: Optional[Mapping[str, Any]] = None,
|
|
90
|
+
contexts: Optional[Mapping[str, Any]] = None,
|
|
91
|
+
extra: Optional[Mapping[str, Any]] = None,
|
|
92
|
+
gzip_threshold_bytes: int = 1024,
|
|
93
|
+
max_queue_bytes: int = 1_048_576,
|
|
94
|
+
offline_path: Optional[str] = None,
|
|
95
|
+
before_send: Optional[Callable[..., Optional[Dict[str, Any]]]] = None,
|
|
96
|
+
before_breadcrumb: Optional[
|
|
97
|
+
Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]
|
|
98
|
+
] = None,
|
|
99
|
+
auto_capture_unhandled: bool = False,
|
|
100
|
+
debug: bool = False,
|
|
101
|
+
sender: Optional[Any] = None,
|
|
102
|
+
) -> Optional[Client]:
|
|
103
|
+
"""Initialize the global Sauron client.
|
|
104
|
+
|
|
105
|
+
A missing/empty ``dsn`` puts the SDK into a disabled no-op mode (logs, does
|
|
106
|
+
not raise) so production code can ship without a DSN configured. A non-empty
|
|
107
|
+
but malformed DSN raises :class:`DsnError`.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
gzip_threshold_bytes: compress the request body with gzip (and set
|
|
111
|
+
``Content-Encoding: gzip``) once it exceeds this size. Default 1024.
|
|
112
|
+
max_queue_bytes: byte budget for the in-memory pending queue; once
|
|
113
|
+
exceeded the oldest items are dropped. Default 1 MiB.
|
|
114
|
+
offline_path: opt-in directory for disk-persisting pending items FIFO
|
|
115
|
+
(reloaded on init, deleted on delivery). Default ``None`` (off).
|
|
116
|
+
auto_capture_unhandled: opt-in (default ``False``) — install
|
|
117
|
+
``sys.excepthook``/``threading.excepthook`` hooks that capture
|
|
118
|
+
uncaught exceptions with ``mechanism.handled=False`` and then
|
|
119
|
+
delegate to the previous hook (default crash/exit behavior preserved).
|
|
120
|
+
sender: optional HTTP sender ``(url, headers, body) -> status`` used in
|
|
121
|
+
place of the built-in ``urllib`` transport (mainly for tests).
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
The created :class:`Client`, or ``None`` when disabled.
|
|
125
|
+
"""
|
|
126
|
+
global _client, _atexit_registered
|
|
127
|
+
|
|
128
|
+
if not dsn:
|
|
129
|
+
if debug:
|
|
130
|
+
import sys
|
|
131
|
+
|
|
132
|
+
print("[sauron] no DSN configured; SDK disabled", file=sys.stderr)
|
|
133
|
+
_client = None
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
_client = Client(
|
|
137
|
+
dsn,
|
|
138
|
+
environment=environment,
|
|
139
|
+
release=release,
|
|
140
|
+
sample_rate=sample_rate,
|
|
141
|
+
flush_interval=flush_interval,
|
|
142
|
+
max_batch=max_batch,
|
|
143
|
+
max_breadcrumbs=max_breadcrumbs,
|
|
144
|
+
tags=tags,
|
|
145
|
+
contexts=contexts,
|
|
146
|
+
extra=extra,
|
|
147
|
+
gzip_threshold_bytes=gzip_threshold_bytes,
|
|
148
|
+
max_queue_bytes=max_queue_bytes,
|
|
149
|
+
offline_path=offline_path,
|
|
150
|
+
before_send=before_send,
|
|
151
|
+
before_breadcrumb=before_breadcrumb,
|
|
152
|
+
auto_capture_unhandled=auto_capture_unhandled,
|
|
153
|
+
debug=debug,
|
|
154
|
+
sender=sender,
|
|
155
|
+
)
|
|
156
|
+
# Graceful shutdown: flush the buffer at interpreter exit. Registered once.
|
|
157
|
+
if not _atexit_registered:
|
|
158
|
+
atexit.register(_atexit_flush)
|
|
159
|
+
_atexit_registered = True
|
|
160
|
+
return _client
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_client() -> Optional[Client]:
|
|
164
|
+
"""Return the active global client, or ``None`` when disabled."""
|
|
165
|
+
return _client
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def track(
|
|
169
|
+
event: str,
|
|
170
|
+
distinct_id: str,
|
|
171
|
+
properties: Optional[Mapping[str, Any]] = None,
|
|
172
|
+
*,
|
|
173
|
+
tags: Optional[Mapping[str, Any]] = None,
|
|
174
|
+
contexts: Optional[Mapping[str, Any]] = None,
|
|
175
|
+
extra: Optional[Mapping[str, Any]] = None,
|
|
176
|
+
) -> None:
|
|
177
|
+
if _client is not None:
|
|
178
|
+
_client.track(
|
|
179
|
+
event,
|
|
180
|
+
distinct_id,
|
|
181
|
+
properties,
|
|
182
|
+
tags=tags,
|
|
183
|
+
contexts=contexts,
|
|
184
|
+
extra=extra,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def capture_exception(
|
|
189
|
+
error: Optional[BaseException] = None,
|
|
190
|
+
*,
|
|
191
|
+
user: Optional[Mapping[str, Any]] = None,
|
|
192
|
+
level: str = "error",
|
|
193
|
+
tags: Optional[Mapping[str, Any]] = None,
|
|
194
|
+
contexts: Optional[Mapping[str, Any]] = None,
|
|
195
|
+
extra: Optional[Mapping[str, Any]] = None,
|
|
196
|
+
fingerprint: Optional[Sequence[str]] = None,
|
|
197
|
+
) -> Optional[str]:
|
|
198
|
+
if _client is not None:
|
|
199
|
+
return _client.capture_exception(
|
|
200
|
+
error,
|
|
201
|
+
user=user,
|
|
202
|
+
level=level,
|
|
203
|
+
tags=tags,
|
|
204
|
+
contexts=contexts,
|
|
205
|
+
extra=extra,
|
|
206
|
+
fingerprint=fingerprint,
|
|
207
|
+
)
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def capture_message(
|
|
212
|
+
message: str,
|
|
213
|
+
level: str = "info",
|
|
214
|
+
*,
|
|
215
|
+
tags: Optional[Mapping[str, Any]] = None,
|
|
216
|
+
contexts: Optional[Mapping[str, Any]] = None,
|
|
217
|
+
extra: Optional[Mapping[str, Any]] = None,
|
|
218
|
+
) -> Optional[str]:
|
|
219
|
+
if _client is not None:
|
|
220
|
+
return _client.capture_message(
|
|
221
|
+
message, level, tags=tags, contexts=contexts, extra=extra
|
|
222
|
+
)
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def identify(
|
|
227
|
+
distinct_id: str,
|
|
228
|
+
traits: Optional[Mapping[str, Any]] = None,
|
|
229
|
+
) -> None:
|
|
230
|
+
if _client is not None:
|
|
231
|
+
_client.identify(distinct_id, traits)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def track_transaction(
|
|
235
|
+
name: str,
|
|
236
|
+
*,
|
|
237
|
+
op: str = "custom",
|
|
238
|
+
duration_ms: float,
|
|
239
|
+
status: Optional[str] = None,
|
|
240
|
+
http_method: Optional[str] = None,
|
|
241
|
+
http_status: Optional[int] = None,
|
|
242
|
+
url: Optional[str] = None,
|
|
243
|
+
distinct_id: Optional[str] = None,
|
|
244
|
+
) -> None:
|
|
245
|
+
"""Emit a performance transaction. No-op before ``init`` / when disabled."""
|
|
246
|
+
if _client is not None:
|
|
247
|
+
_client.track_transaction(
|
|
248
|
+
name,
|
|
249
|
+
op=op,
|
|
250
|
+
duration_ms=duration_ms,
|
|
251
|
+
status=status,
|
|
252
|
+
http_method=http_method,
|
|
253
|
+
http_status=http_status,
|
|
254
|
+
url=url,
|
|
255
|
+
distinct_id=distinct_id,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# -- scope + breadcrumbs (operate on the active scope) ---------------------
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def add_breadcrumb(
|
|
263
|
+
*,
|
|
264
|
+
type: Optional[str] = None,
|
|
265
|
+
category: Optional[str] = None,
|
|
266
|
+
message: Optional[str] = None,
|
|
267
|
+
level: Optional[str] = None,
|
|
268
|
+
data: Optional[Mapping[str, Any]] = None,
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Record a breadcrumb on the active scope.
|
|
271
|
+
|
|
272
|
+
When a client is initialized the ``before_breadcrumb`` hook runs first;
|
|
273
|
+
before init this seeds the global scope (no hook, never raises).
|
|
274
|
+
"""
|
|
275
|
+
if _client is not None:
|
|
276
|
+
_client.add_breadcrumb(
|
|
277
|
+
type=type,
|
|
278
|
+
category=category,
|
|
279
|
+
message=message,
|
|
280
|
+
level=level,
|
|
281
|
+
data=data,
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
get_current_scope().add_breadcrumb(
|
|
285
|
+
build_breadcrumb(
|
|
286
|
+
type=type,
|
|
287
|
+
category=category,
|
|
288
|
+
message=message,
|
|
289
|
+
level=level,
|
|
290
|
+
data=data,
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def set_user(user: Optional[Mapping[str, Any]]) -> None:
|
|
296
|
+
"""Set (or clear) the active scope's fallback user."""
|
|
297
|
+
get_current_scope().set_user(user)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def set_tag(key: str, value: Any) -> None:
|
|
301
|
+
get_current_scope().set_tag(key, value)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def set_tags(tags: Mapping[str, Any]) -> None:
|
|
305
|
+
get_current_scope().set_tags(tags)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def set_context(key: str, value: Any) -> None:
|
|
309
|
+
get_current_scope().set_context(key, value)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def set_extra(key: str, value: Any) -> None:
|
|
313
|
+
get_current_scope().set_extra(key, value)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def flush(timeout: Optional[float] = None) -> bool:
|
|
317
|
+
if _client is not None:
|
|
318
|
+
return _client.flush(timeout)
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def close(timeout: Optional[float] = None) -> None:
|
|
323
|
+
global _client
|
|
324
|
+
if _client is not None:
|
|
325
|
+
_client.close(timeout)
|
|
326
|
+
_client = None
|
sauron/_autocapture.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Opt-in capture of *uncaught* exceptions.
|
|
2
|
+
|
|
3
|
+
Off by default (the ``auto_capture_unhandled`` init flag). When enabled,
|
|
4
|
+
:func:`install_excepthook` wraps ``sys.excepthook`` — and ``threading.excepthook``
|
|
5
|
+
on Python 3.8+ — so a crash that reaches the top level is captured with
|
|
6
|
+
``mechanism.handled = False`` and flushed **before** delegating to the previously
|
|
7
|
+
installed hook. Delegating last preserves the interpreter's default crash
|
|
8
|
+
behavior (print the traceback, non-zero exit); the SDK never swallows the error.
|
|
9
|
+
|
|
10
|
+
``KeyboardInterrupt`` (Ctrl-C) is deliberately *not* captured — it is a user
|
|
11
|
+
signal, not an application fault — but the previous hook still runs so exit
|
|
12
|
+
semantics are unchanged.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
from typing import Any, Callable
|
|
20
|
+
|
|
21
|
+
# The mechanism stamped onto errors that came from an uncaught handler. Mirrors
|
|
22
|
+
# the browser SDK's ``onunhandledrejection`` marker: ``handled = False``.
|
|
23
|
+
_SYS_MECHANISM = {"type": "excepthook", "handled": False}
|
|
24
|
+
_THREAD_MECHANISM = {"type": "threading.excepthook", "handled": False}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _should_capture(exc_type: type) -> bool:
|
|
28
|
+
"""Skip user-initiated interrupts; capture genuine faults."""
|
|
29
|
+
return not (
|
|
30
|
+
exc_type is None or issubclass(exc_type, (KeyboardInterrupt,))
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def install_excepthook(client: Any) -> Callable[[], None]:
|
|
35
|
+
"""Install uncaught-exception hooks that report through ``client``.
|
|
36
|
+
|
|
37
|
+
Returns an idempotent uninstaller that restores the previous hooks (only if
|
|
38
|
+
ours are still the active ones — so nested installs unwind cleanly).
|
|
39
|
+
"""
|
|
40
|
+
previous_sys_hook = sys.excepthook
|
|
41
|
+
previous_thread_hook = getattr(threading, "excepthook", None)
|
|
42
|
+
|
|
43
|
+
def sys_hook(exc_type, exc, tb):
|
|
44
|
+
if _should_capture(exc_type):
|
|
45
|
+
try:
|
|
46
|
+
client.capture_exception(
|
|
47
|
+
exc, level="fatal", mechanism=_SYS_MECHANISM
|
|
48
|
+
)
|
|
49
|
+
client.flush()
|
|
50
|
+
except Exception: # a reporting failure must never mask the crash
|
|
51
|
+
pass
|
|
52
|
+
# Preserve default crash/exit behavior — delegate to the prior hook.
|
|
53
|
+
previous_sys_hook(exc_type, exc, tb)
|
|
54
|
+
|
|
55
|
+
def thread_hook(args):
|
|
56
|
+
exc_type = args.exc_type
|
|
57
|
+
exc = args.exc_value
|
|
58
|
+
if exc is not None and _should_capture(exc_type):
|
|
59
|
+
try:
|
|
60
|
+
client.capture_exception(
|
|
61
|
+
exc, level="fatal", mechanism=_THREAD_MECHANISM
|
|
62
|
+
)
|
|
63
|
+
client.flush()
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
if previous_thread_hook is not None:
|
|
67
|
+
previous_thread_hook(args)
|
|
68
|
+
|
|
69
|
+
sys.excepthook = sys_hook
|
|
70
|
+
if previous_thread_hook is not None:
|
|
71
|
+
threading.excepthook = thread_hook
|
|
72
|
+
|
|
73
|
+
def uninstall() -> None:
|
|
74
|
+
if sys.excepthook is sys_hook:
|
|
75
|
+
sys.excepthook = previous_sys_hook
|
|
76
|
+
if (
|
|
77
|
+
previous_thread_hook is not None
|
|
78
|
+
and getattr(threading, "excepthook", None) is thread_hook
|
|
79
|
+
):
|
|
80
|
+
threading.excepthook = previous_thread_hook
|
|
81
|
+
|
|
82
|
+
return uninstall
|