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 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