vaka-sdk 0.2.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.
- vaka/__init__.py +63 -0
- vaka/_api.py +254 -0
- vaka/_client.py +451 -0
- vaka/_stack.py +188 -0
- vaka/_transport.py +200 -0
- vaka/_version.py +1 -0
- vaka/flags.py +78 -0
- vaka/integrations/__init__.py +0 -0
- vaka/integrations/_request.py +50 -0
- vaka/integrations/asgi.py +70 -0
- vaka/integrations/django.py +46 -0
- vaka/integrations/flask.py +41 -0
- vaka/integrations/logging.py +110 -0
- vaka/integrations/wsgi.py +67 -0
- vaka/otel.py +160 -0
- vaka/py.typed +0 -0
- vaka_sdk-0.2.0.dist-info/METADATA +60 -0
- vaka_sdk-0.2.0.dist-info/RECORD +21 -0
- vaka_sdk-0.2.0.dist-info/WHEEL +4 -0
- vaka_sdk-0.2.0.dist-info/licenses/LICENSE-APACHE +202 -0
- vaka_sdk-0.2.0.dist-info/licenses/LICENSE-MIT +21 -0
vaka/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Vaka SDK for Python: crash reporting, release health, analytics and feature flags.
|
|
2
|
+
|
|
3
|
+
import vaka
|
|
4
|
+
vaka.init(key="vk_...", endpoint="https://vaka.example.com", release="2.4.0")
|
|
5
|
+
|
|
6
|
+
Unhandled exceptions (main thread and ``threading`` threads), ``logging`` errors and one session
|
|
7
|
+
per process are reported automatically. Traces and logs go through OpenTelemetry: see
|
|
8
|
+
``vaka.otel.setup()``. Docs: https://vaka.hellsoft.se/docs/sdk-python
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._api import (
|
|
12
|
+
add_breadcrumb,
|
|
13
|
+
capture_exception,
|
|
14
|
+
capture_message,
|
|
15
|
+
evaluate_flags,
|
|
16
|
+
flush,
|
|
17
|
+
get_client,
|
|
18
|
+
get_flag,
|
|
19
|
+
identify,
|
|
20
|
+
init,
|
|
21
|
+
is_enabled,
|
|
22
|
+
is_initialized,
|
|
23
|
+
load_flags,
|
|
24
|
+
monitor,
|
|
25
|
+
set_context,
|
|
26
|
+
set_tag,
|
|
27
|
+
set_tags,
|
|
28
|
+
set_user,
|
|
29
|
+
shutdown,
|
|
30
|
+
track,
|
|
31
|
+
)
|
|
32
|
+
from ._client import Client, Options, Scope, current_scope, isolation_scope
|
|
33
|
+
from ._version import __version__
|
|
34
|
+
from .flags import Flags
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"__version__",
|
|
38
|
+
"init",
|
|
39
|
+
"get_client",
|
|
40
|
+
"is_initialized",
|
|
41
|
+
"capture_exception",
|
|
42
|
+
"capture_message",
|
|
43
|
+
"add_breadcrumb",
|
|
44
|
+
"set_user",
|
|
45
|
+
"identify",
|
|
46
|
+
"set_tag",
|
|
47
|
+
"set_tags",
|
|
48
|
+
"set_context",
|
|
49
|
+
"track",
|
|
50
|
+
"flush",
|
|
51
|
+
"shutdown",
|
|
52
|
+
"monitor",
|
|
53
|
+
"load_flags",
|
|
54
|
+
"evaluate_flags",
|
|
55
|
+
"get_flag",
|
|
56
|
+
"is_enabled",
|
|
57
|
+
"isolation_scope",
|
|
58
|
+
"current_scope",
|
|
59
|
+
"Client",
|
|
60
|
+
"Options",
|
|
61
|
+
"Scope",
|
|
62
|
+
"Flags",
|
|
63
|
+
]
|
vaka/_api.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Module-level API (``vaka.init``, ``vaka.capture_exception`` …) around one global client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any, Callable, Dict, Mapping, Optional, TypeVar
|
|
8
|
+
|
|
9
|
+
from . import flags as _flags
|
|
10
|
+
from ._client import Client, Options, Scope, current_scope, isolation_scope, now_ms, MAX_BREADCRUMBS
|
|
11
|
+
|
|
12
|
+
_client: Optional[Client] = None
|
|
13
|
+
_lock = threading.Lock()
|
|
14
|
+
|
|
15
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def init(key: Optional[str] = None, endpoint: Optional[str] = None, **options: Any) -> Optional[Client]:
|
|
19
|
+
"""Start the SDK. Call once, as early as possible.
|
|
20
|
+
|
|
21
|
+
``key`` and ``endpoint`` default to ``$VAKA_KEY`` and ``$VAKA_ENDPOINT``; without both the SDK
|
|
22
|
+
stays disabled and every call is a no-op. See ``vaka.Options`` for the other settings.
|
|
23
|
+
Calling ``init`` again replaces the previous client (it is flushed and closed first).
|
|
24
|
+
"""
|
|
25
|
+
global _client
|
|
26
|
+
opts = Options(key=key, endpoint=endpoint, **options)
|
|
27
|
+
if not opts.key or not opts.endpoint:
|
|
28
|
+
if opts.debug or key or endpoint:
|
|
29
|
+
print("[vaka] init: `key` and `endpoint` are required; SDK disabled", file=sys.stderr)
|
|
30
|
+
return None
|
|
31
|
+
with _lock:
|
|
32
|
+
old, _client = _client, None
|
|
33
|
+
if old is not None:
|
|
34
|
+
old.close(timeout=2.0)
|
|
35
|
+
client = Client(opts)
|
|
36
|
+
with _lock:
|
|
37
|
+
_client = client
|
|
38
|
+
client.install_hooks()
|
|
39
|
+
if opts.sessions:
|
|
40
|
+
client.start_session()
|
|
41
|
+
if opts.flags:
|
|
42
|
+
threading.Thread(target=_load_flags_quietly, name="vaka-flags", daemon=True).start()
|
|
43
|
+
client.log(f"initialized (release={opts.release}, environment={opts.environment})")
|
|
44
|
+
return client
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_client() -> Optional[Client]:
|
|
48
|
+
return _client
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_initialized() -> bool:
|
|
52
|
+
return _client is not None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def capture_exception(exc: Optional[BaseException] = None, **kwargs: Any) -> Optional[str]:
|
|
56
|
+
"""Report an exception (default: the one being handled). Returns the event id.
|
|
57
|
+
|
|
58
|
+
Keyword arguments: ``level`` ("error"), ``tags``, ``contexts``, ``fingerprint``, ``handled``.
|
|
59
|
+
"""
|
|
60
|
+
c = _client
|
|
61
|
+
return c.capture_exception(exc, **kwargs) if c else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def capture_message(message: str, level: str = "info", **kwargs: Any) -> Optional[str]:
|
|
65
|
+
"""Report a message with the current stack. Returns the event id."""
|
|
66
|
+
c = _client
|
|
67
|
+
return c.capture_message(message, level=level, **kwargs) if c else None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def add_breadcrumb(
|
|
71
|
+
message: str,
|
|
72
|
+
category: str = "default",
|
|
73
|
+
level: str = "info",
|
|
74
|
+
data: Optional[Mapping[str, Any]] = None,
|
|
75
|
+
type: Optional[str] = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Record a breadcrumb; the last 100 are sent with the next error.
|
|
78
|
+
|
|
79
|
+
Categories with special meaning: ``navigation`` (``data: {from, to}``), ``http`` (``data:
|
|
80
|
+
{method, url, status_code, duration_ms}``), ``console``, ``lifecycle``.
|
|
81
|
+
"""
|
|
82
|
+
if _client is None:
|
|
83
|
+
return
|
|
84
|
+
crumb: Dict[str, Any] = {"timestamp": now_ms(), "category": category, "message": message, "level": level}
|
|
85
|
+
if data:
|
|
86
|
+
crumb["data"] = dict(data)
|
|
87
|
+
if type:
|
|
88
|
+
crumb["type"] = type
|
|
89
|
+
crumbs = current_scope().breadcrumbs
|
|
90
|
+
crumbs.append(crumb)
|
|
91
|
+
if len(crumbs) > MAX_BREADCRUMBS:
|
|
92
|
+
del crumbs[: len(crumbs) - MAX_BREADCRUMBS]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def set_user(user_id: Optional[str]) -> None:
|
|
96
|
+
"""Set (or clear with ``None``) the identified user. Opt-in: only if your privacy policy allows."""
|
|
97
|
+
current_scope().user_id = str(user_id) if user_id is not None else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
identify = set_user
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def set_tag(key: str, value: Any) -> None:
|
|
104
|
+
"""Set a tag sent with every error from the current scope."""
|
|
105
|
+
current_scope().tags[key] = str(value)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def set_tags(tags: Mapping[str, Any]) -> None:
|
|
109
|
+
for k, v in tags.items():
|
|
110
|
+
set_tag(k, v)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def set_context(name: str, value: Mapping[str, Any]) -> None:
|
|
114
|
+
"""Attach structured context (shown on errors under ``contexts.<name>``)."""
|
|
115
|
+
current_scope().contexts[name] = dict(value)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def track(name: str, props: Optional[Mapping[str, Any]] = None, **fields: Any) -> None:
|
|
119
|
+
"""Track a custom analytics event. ``props``: flat str/number/bool values, at most 64."""
|
|
120
|
+
c = _client
|
|
121
|
+
if c is not None:
|
|
122
|
+
c.track(name, props, **fields)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def flush(timeout: float = 2.0) -> bool:
|
|
126
|
+
"""Send everything queued, waiting at most ``timeout`` seconds."""
|
|
127
|
+
c = _client
|
|
128
|
+
return c.flush(timeout) if c else True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def shutdown(timeout: float = 5.0) -> None:
|
|
132
|
+
"""End the session, flush and stop the SDK (also runs automatically at exit)."""
|
|
133
|
+
global _client
|
|
134
|
+
with _lock:
|
|
135
|
+
c, _client = _client, None
|
|
136
|
+
if c is not None:
|
|
137
|
+
c.close(timeout)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def monitor(fn: F) -> F:
|
|
141
|
+
"""Decorator: report exceptions escaping ``fn`` (re-raised) inside an isolation scope.
|
|
142
|
+
|
|
143
|
+
Handy for background jobs, task-queue workers and serverless handlers; also flushes.
|
|
144
|
+
"""
|
|
145
|
+
import functools
|
|
146
|
+
import inspect
|
|
147
|
+
|
|
148
|
+
if inspect.iscoroutinefunction(fn):
|
|
149
|
+
|
|
150
|
+
@functools.wraps(fn)
|
|
151
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
152
|
+
with isolation_scope():
|
|
153
|
+
try:
|
|
154
|
+
return await fn(*args, **kwargs)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
capture_exception(e, handled=False, tags={"mechanism": "monitor", "function": fn.__qualname__})
|
|
157
|
+
flush()
|
|
158
|
+
raise
|
|
159
|
+
|
|
160
|
+
return async_wrapper # type: ignore[return-value]
|
|
161
|
+
|
|
162
|
+
@functools.wraps(fn)
|
|
163
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
164
|
+
with isolation_scope():
|
|
165
|
+
try:
|
|
166
|
+
return fn(*args, **kwargs)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
capture_exception(e, handled=False, tags={"mechanism": "monitor", "function": fn.__qualname__})
|
|
169
|
+
flush()
|
|
170
|
+
raise
|
|
171
|
+
|
|
172
|
+
return wrapper # type: ignore[return-value]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ----- feature flags ---------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def load_flags(
|
|
179
|
+
properties: Optional[Mapping[str, Any]] = None,
|
|
180
|
+
distinct_id: Optional[str] = None,
|
|
181
|
+
user_id: Optional[str] = None,
|
|
182
|
+
timeout: float = 3.0,
|
|
183
|
+
) -> _flags.Flags:
|
|
184
|
+
"""Evaluate all flags for this process (or ``distinct_id``) and cache them for ``get_flag``."""
|
|
185
|
+
c = _client
|
|
186
|
+
if c is None:
|
|
187
|
+
return _flags.Flags()
|
|
188
|
+
props = dict(c.options.flag_properties)
|
|
189
|
+
props.update(properties or {})
|
|
190
|
+
result = _flags.evaluate(
|
|
191
|
+
c.options.endpoint,
|
|
192
|
+
c.options.key,
|
|
193
|
+
distinct_id or c.distinct_id,
|
|
194
|
+
user_id=user_id or current_scope().user_id,
|
|
195
|
+
environment=c.options.environment,
|
|
196
|
+
properties=props,
|
|
197
|
+
timeout=timeout,
|
|
198
|
+
)
|
|
199
|
+
c.flags = result
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _load_flags_quietly() -> None:
|
|
204
|
+
try:
|
|
205
|
+
load_flags()
|
|
206
|
+
except Exception as e:
|
|
207
|
+
c = _client
|
|
208
|
+
if c is not None:
|
|
209
|
+
c.log(f"loading flags failed: {e}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def evaluate_flags(
|
|
213
|
+
distinct_id: str,
|
|
214
|
+
user_id: Optional[str] = None,
|
|
215
|
+
properties: Optional[Mapping[str, Any]] = None,
|
|
216
|
+
timeout: float = 3.0,
|
|
217
|
+
) -> _flags.Flags:
|
|
218
|
+
"""Evaluate flags for one person (e.g. per request on a server). Nothing is cached.
|
|
219
|
+
|
|
220
|
+
Values read with ``.get()`` are recorded on the current (request) scope.
|
|
221
|
+
"""
|
|
222
|
+
c = _client
|
|
223
|
+
if c is None:
|
|
224
|
+
return _flags.Flags()
|
|
225
|
+
props = dict(c.options.flag_properties)
|
|
226
|
+
props.update(properties or {})
|
|
227
|
+
return _flags.evaluate(
|
|
228
|
+
c.options.endpoint, c.options.key, distinct_id, user_id=user_id,
|
|
229
|
+
environment=c.options.environment, properties=props, timeout=timeout,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def get_flag(key: str, default: Any = None) -> Any:
|
|
234
|
+
"""A flag value from the last ``load_flags()`` (recorded as an exposure)."""
|
|
235
|
+
c = _client
|
|
236
|
+
if c is None or c.flags is None:
|
|
237
|
+
if default is not None:
|
|
238
|
+
_flags.expose(key, default)
|
|
239
|
+
return default
|
|
240
|
+
return c.flags.get(key, default)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def is_enabled(key: str, default: bool = False) -> bool:
|
|
244
|
+
c = _client
|
|
245
|
+
if c is None or c.flags is None:
|
|
246
|
+
return default
|
|
247
|
+
return c.flags.is_enabled(key, default)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
__all__ = [
|
|
251
|
+
"init", "get_client", "is_initialized", "capture_exception", "capture_message", "add_breadcrumb",
|
|
252
|
+
"set_user", "identify", "set_tag", "set_tags", "set_context", "track", "flush", "shutdown",
|
|
253
|
+
"monitor", "load_flags", "evaluate_flags", "get_flag", "is_enabled", "isolation_scope", "Scope",
|
|
254
|
+
]
|