sdk-garden-python 0.1.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.
sdk_garden/__init__.py
ADDED
sdk_garden/client.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sdk_garden.client — Lightweight SDK observability client for Python.
|
|
3
|
+
|
|
4
|
+
Stdlib only, non-blocking, zero runtime dependencies.
|
|
5
|
+
Events are queued on the caller's thread and flushed in batches by a daemon thread.
|
|
6
|
+
All network errors are caught and discarded — no exception ever reaches the caller.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import json
|
|
11
|
+
import platform
|
|
12
|
+
import queue
|
|
13
|
+
import re
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
import uuid
|
|
19
|
+
from typing import Any, Callable, Dict, Optional, TypeVar
|
|
20
|
+
|
|
21
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Module-level constants
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
_DEFAULT_ENDPOINT = "https://ingest.sdkgarden.dev/track"
|
|
28
|
+
|
|
29
|
+
#: Maximum number of events sent in one HTTP POST.
|
|
30
|
+
_BATCH_SIZE = 10 # Claude's Discretion — per CONTEXT.md
|
|
31
|
+
|
|
32
|
+
#: Seconds the daemon thread sleeps between flush cycles.
|
|
33
|
+
_FLUSH_INTERVAL = 5.0 # seconds — Claude's Discretion
|
|
34
|
+
|
|
35
|
+
#: HTTP request timeout in seconds. Locked by CLIENT-02.
|
|
36
|
+
_HTTP_TIMEOUT = 2 # seconds — locked by CLIENT-02
|
|
37
|
+
|
|
38
|
+
#: Email-address PII pattern ported verbatim from sdk-garden.js lines 174-177.
|
|
39
|
+
#: When this module was imported: the default start of the host SDK's init.
|
|
40
|
+
_MODULE_LOADED_AT = time.time()
|
|
41
|
+
|
|
42
|
+
_PII_PATTERN = re.compile(
|
|
43
|
+
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SdkGarden:
|
|
48
|
+
"""Non-blocking SDK observability client — stdlib only.
|
|
49
|
+
|
|
50
|
+
Instantiate and call `.start()` to begin flushing:
|
|
51
|
+
|
|
52
|
+
sg = SdkGarden(api_key="sgk_...", sdk_name="my-sdk", sdk_version="1.0.0").start()
|
|
53
|
+
|
|
54
|
+
``start()`` sends the ``sdk_initialized`` event (runtime, Python version and
|
|
55
|
+
``time_to_init_ms``) for you.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
api_key: str,
|
|
61
|
+
sdk_name: str,
|
|
62
|
+
sdk_version: str,
|
|
63
|
+
endpoint: str = _DEFAULT_ENDPOINT,
|
|
64
|
+
debug: bool = False,
|
|
65
|
+
disabled: bool = False,
|
|
66
|
+
batch_size: int = _BATCH_SIZE,
|
|
67
|
+
flush_interval: float = _FLUSH_INTERVAL,
|
|
68
|
+
started_at: Optional[float] = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Create a client. Nothing is sent until ``start()``.
|
|
71
|
+
|
|
72
|
+
``started_at`` is when your SDK began initializing, as ``time.time()``;
|
|
73
|
+
``start()`` reports the difference as ``time_to_init_ms``. Defaults to
|
|
74
|
+
when sdk_garden was imported.
|
|
75
|
+
"""
|
|
76
|
+
self._api_key = api_key
|
|
77
|
+
self._sdk_name = sdk_name
|
|
78
|
+
self._sdk_version = sdk_version
|
|
79
|
+
self._endpoint = endpoint
|
|
80
|
+
self._debug = debug
|
|
81
|
+
self._disabled = disabled
|
|
82
|
+
self._batch_size = batch_size
|
|
83
|
+
self._flush_interval = flush_interval
|
|
84
|
+
self._started_at = started_at if started_at is not None else _MODULE_LOADED_AT
|
|
85
|
+
|
|
86
|
+
#: True once start() has sent sdk_initialized for this session.
|
|
87
|
+
self._init_sent = False
|
|
88
|
+
|
|
89
|
+
#: Thread-safe event queue. maxsize=100 locked by D-04/D-05.
|
|
90
|
+
self._queue: queue.Queue = queue.Queue(maxsize=100)
|
|
91
|
+
|
|
92
|
+
#: Per-instance session identifier (UUID4).
|
|
93
|
+
self._session_id: str = str(uuid.uuid4())
|
|
94
|
+
|
|
95
|
+
#: Daemon flush thread — None until start() is called.
|
|
96
|
+
self._thread: Optional[threading.Thread] = None
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# Public API
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
def start(self) -> "SdkGarden":
|
|
103
|
+
"""Spawn the daemon flush thread and return self for chaining.
|
|
104
|
+
|
|
105
|
+
When disabled=True, returns immediately without spawning a thread.
|
|
106
|
+
Idempotent — subsequent calls while the thread is alive are no-ops.
|
|
107
|
+
"""
|
|
108
|
+
if self._disabled:
|
|
109
|
+
return self
|
|
110
|
+
if self._thread is not None and self._thread.is_alive():
|
|
111
|
+
return self
|
|
112
|
+
if not self._init_sent:
|
|
113
|
+
self.track(
|
|
114
|
+
"sdk_initialized",
|
|
115
|
+
{
|
|
116
|
+
"environment": self._get_environment(),
|
|
117
|
+
"time_to_init_ms": max(0, int((time.time() - self._started_at) * 1000)),
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
self._init_sent = True
|
|
121
|
+
self._thread = threading.Thread(target=self._flush_loop, daemon=True)
|
|
122
|
+
self._thread.start()
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def track(
|
|
126
|
+
self,
|
|
127
|
+
event_name: str,
|
|
128
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
129
|
+
) -> None:
|
|
130
|
+
"""Enqueue a named event.
|
|
131
|
+
|
|
132
|
+
Silently discards if the queue is full (D-05) or disabled.
|
|
133
|
+
Never blocks the caller.
|
|
134
|
+
"""
|
|
135
|
+
if self._disabled:
|
|
136
|
+
return
|
|
137
|
+
if event_name == "sdk_initialized" and self._init_sent:
|
|
138
|
+
# start() already reported it; a manual call (as older docs
|
|
139
|
+
# suggested) would double-count the session.
|
|
140
|
+
return
|
|
141
|
+
event = {
|
|
142
|
+
"event": event_name,
|
|
143
|
+
"sdk": {"name": self._sdk_name, "version": self._sdk_version},
|
|
144
|
+
"session_id": self._session_id,
|
|
145
|
+
"timestamp": int(time.time() * 1000),
|
|
146
|
+
"properties": properties or {},
|
|
147
|
+
"meta": {},
|
|
148
|
+
}
|
|
149
|
+
try:
|
|
150
|
+
self._queue.put_nowait(event)
|
|
151
|
+
except queue.Full:
|
|
152
|
+
pass # Silent discard — locked by D-05, never blocks caller
|
|
153
|
+
|
|
154
|
+
def track_method(self, method_name: str) -> Callable[[F], F]:
|
|
155
|
+
"""Return a decorator that wraps a function with method-call telemetry.
|
|
156
|
+
|
|
157
|
+
Always enqueues a ``method_called`` event before the call.
|
|
158
|
+
On success: also enqueues a ``method_success`` event with ``duration_ms``.
|
|
159
|
+
On exception: enqueues an ``sdk_error`` event with PII-scrubbed message
|
|
160
|
+
and re-raises the original exception.
|
|
161
|
+
|
|
162
|
+
When disabled, the original function is returned unwrapped.
|
|
163
|
+
"""
|
|
164
|
+
if self._disabled:
|
|
165
|
+
def _noop_decorator(fn: F) -> F:
|
|
166
|
+
return fn
|
|
167
|
+
|
|
168
|
+
return _noop_decorator # type: ignore[return-value]
|
|
169
|
+
|
|
170
|
+
def decorator(fn: F) -> F:
|
|
171
|
+
@functools.wraps(fn)
|
|
172
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
173
|
+
start = time.time()
|
|
174
|
+
self.track(
|
|
175
|
+
"method_called",
|
|
176
|
+
{"method": method_name, "args_count": len(args)},
|
|
177
|
+
)
|
|
178
|
+
try:
|
|
179
|
+
result = fn(*args, **kwargs)
|
|
180
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
181
|
+
self.track(
|
|
182
|
+
"method_success",
|
|
183
|
+
{
|
|
184
|
+
"method": method_name,
|
|
185
|
+
"duration_ms": duration_ms,
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
return result
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
191
|
+
self.track_error(exc, {"method": method_name, "duration_ms": duration_ms})
|
|
192
|
+
raise
|
|
193
|
+
|
|
194
|
+
return wrapper # type: ignore[return-value]
|
|
195
|
+
|
|
196
|
+
return decorator # type: ignore[return-value]
|
|
197
|
+
|
|
198
|
+
def track_error(
|
|
199
|
+
self,
|
|
200
|
+
error: Exception,
|
|
201
|
+
context: Optional[Dict[str, Any]] = None,
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Enqueue an ``sdk_error`` event with PII-scrubbed error message.
|
|
204
|
+
|
|
205
|
+
Silently discards if disabled.
|
|
206
|
+
"""
|
|
207
|
+
if self._disabled:
|
|
208
|
+
return
|
|
209
|
+
props: Dict[str, Any] = {
|
|
210
|
+
"error_message": self._scrub_pii(str(error)),
|
|
211
|
+
"error_type": type(error).__name__,
|
|
212
|
+
}
|
|
213
|
+
if context:
|
|
214
|
+
props.update(context)
|
|
215
|
+
self.track("sdk_error", props)
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
# Internal helpers
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def _flush_loop(self) -> None:
|
|
222
|
+
"""Daemon thread target — wakes every flush_interval and drains the queue."""
|
|
223
|
+
while True:
|
|
224
|
+
self._flush_batch()
|
|
225
|
+
time.sleep(self._flush_interval)
|
|
226
|
+
|
|
227
|
+
def _flush_batch(self) -> None:
|
|
228
|
+
"""Drain up to batch_size events from the queue and POST them.
|
|
229
|
+
|
|
230
|
+
Silent on all network errors (Pitfall 5: broad except required because
|
|
231
|
+
socket.timeout is not a subclass of urllib.error.URLError).
|
|
232
|
+
"""
|
|
233
|
+
events = []
|
|
234
|
+
try:
|
|
235
|
+
while len(events) < self._batch_size:
|
|
236
|
+
events.append(self._queue.get_nowait())
|
|
237
|
+
except queue.Empty:
|
|
238
|
+
pass
|
|
239
|
+
|
|
240
|
+
if not events:
|
|
241
|
+
return # Nothing to flush — no HTTP call
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
payload = json.dumps(
|
|
245
|
+
{
|
|
246
|
+
"api_key": self._api_key,
|
|
247
|
+
"events": events,
|
|
248
|
+
}
|
|
249
|
+
).encode("utf-8")
|
|
250
|
+
req = urllib.request.Request(
|
|
251
|
+
self._endpoint,
|
|
252
|
+
data=payload,
|
|
253
|
+
headers={"Content-Type": "application/json"},
|
|
254
|
+
method="POST",
|
|
255
|
+
)
|
|
256
|
+
urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT)
|
|
257
|
+
except Exception:
|
|
258
|
+
pass # Silent failure — ALL exceptions swallowed (Pitfall 5)
|
|
259
|
+
|
|
260
|
+
def _scrub_pii(self, s: Any) -> Any:
|
|
261
|
+
"""Replace email addresses in a string with '[REDACTED]'.
|
|
262
|
+
|
|
263
|
+
Non-string values are returned unchanged.
|
|
264
|
+
Ported verbatim from sdk-garden.js lines 174-177.
|
|
265
|
+
"""
|
|
266
|
+
if not isinstance(s, str):
|
|
267
|
+
return s
|
|
268
|
+
return _PII_PATTERN.sub("[REDACTED]", s)
|
|
269
|
+
|
|
270
|
+
def _log(self, *args: Any) -> None:
|
|
271
|
+
"""Print a debug message when debug=True.
|
|
272
|
+
|
|
273
|
+
NEVER pass self._api_key directly to this method.
|
|
274
|
+
"""
|
|
275
|
+
if self._debug:
|
|
276
|
+
print("[SDK Garden]", *args)
|
|
277
|
+
|
|
278
|
+
def _get_environment(self) -> Dict[str, str]:
|
|
279
|
+
"""Return runtime environment metadata for the sdk_initialized event."""
|
|
280
|
+
return {
|
|
281
|
+
"runtime": platform.python_implementation().lower(),
|
|
282
|
+
"runtime_version": platform.python_version(),
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
def _generate_session_id(self) -> str:
|
|
286
|
+
"""Generate a random UUID4 session identifier."""
|
|
287
|
+
return str(uuid.uuid4())
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sdk-garden-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Lightweight SDK observability client for Python — stdlib only, non-blocking
|
|
5
|
+
Project-URL: Homepage, https://sdkgarden.dev
|
|
6
|
+
Project-URL: Repository, https://github.com/tjb/SDKGarden
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# sdk-garden-python
|
|
12
|
+
*Lightweight SDK observability client for Python — stdlib only, non-blocking*
|
|
13
|
+
|
|
14
|
+
Add SDK Garden telemetry to your Python SDK in minutes. Events are sent on a background daemon thread — your users never see a delay or an exception from the instrumentation layer.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install sdk-garden-python
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from sdk_garden import SdkGarden
|
|
26
|
+
|
|
27
|
+
sg = SdkGarden(api_key="YOUR_API_KEY", sdk_name="my-sdk", sdk_version="1.0.0").start()
|
|
28
|
+
|
|
29
|
+
@sg.track_method("process_payment")
|
|
30
|
+
def process_payment(amount):
|
|
31
|
+
...
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Track arbitrary events:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
sg.track("feature_used", properties={"feature": "streaming"})
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Track errors with automatic PII scrubbing:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
try:
|
|
44
|
+
process_payment(100)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
sg.track_error(e)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
| Parameter | Type | Default | Description |
|
|
52
|
+
|-----------|------|---------|-------------|
|
|
53
|
+
| `api_key` | `str` | required | Your SDK Garden API key (`sgk_...`) |
|
|
54
|
+
| `sdk_name` | `str` | required | Name of your SDK (e.g. `"my-payments-sdk"`) |
|
|
55
|
+
| `sdk_version` | `str` | required | Version of your SDK (e.g. `"1.2.3"`) |
|
|
56
|
+
| `endpoint` | `str` | `https://ingest.sdkgarden.dev/track` | Ingest endpoint URL |
|
|
57
|
+
| `debug` | `bool` | `False` | Print `[SDK Garden]` debug lines to stdout |
|
|
58
|
+
| `disabled` | `bool` | `False` | Disable all tracking (no thread spawned, no network calls) |
|
|
59
|
+
| `batch_size` | `int` | `10` | Maximum events per HTTP POST |
|
|
60
|
+
| `flush_interval` | `float` | `5.0` | Seconds between flush cycles |
|
|
61
|
+
| `started_at` | `float` | import time | `time.time()` when your SDK began initializing; reported as `time_to_init_ms` for the dashboard's init timing |
|
|
62
|
+
|
|
63
|
+
## What gets sent
|
|
64
|
+
|
|
65
|
+
Each event carries your SDK's name and version, an event type, a random session ID created per client instance, and a timestamp. Depending on the event:
|
|
66
|
+
|
|
67
|
+
- **`sdk_initialized`** (sent by `start()`): Python implementation and version, and `time_to_init_ms`.
|
|
68
|
+
- **`method_called` / `method_success`**: method name, duration, and the *number* of positional arguments.
|
|
69
|
+
- **`sdk_error`**: error type and message (email addresses are redacted) plus the method and duration.
|
|
70
|
+
|
|
71
|
+
Never sent: argument or return values, request or response bodies, stack traces, hostnames, environment variables or user identifiers. Nothing is sent when `disabled=True`.
|
|
72
|
+
|
|
73
|
+
### Telling your users
|
|
74
|
+
|
|
75
|
+
Copy this into your SDK's README or privacy docs so your users know what's collected (edit the names to match):
|
|
76
|
+
|
|
77
|
+
> **Telemetry.** This SDK reports anonymous usage data to its maintainers through [SDK Garden](https://github.com/tjb/SDKGarden): the SDK version, runtime version, which SDK methods are called, how long they take, and error types. It never sends your data, request contents or personal information. To opt out, set `YOUR_SDK_TELEMETRY_DISABLED=1` *(wire this environment variable to `disabled=True` in your SDK)*.
|
|
78
|
+
|
|
79
|
+
## Behaviour Guarantees
|
|
80
|
+
|
|
81
|
+
**All network errors are caught and discarded. Your program will never see an exception from sdk-garden-python.**
|
|
82
|
+
|
|
83
|
+
**Runs on a daemon thread — your program exits normally even if events are queued.** When the process exits the daemon thread is killed automatically; any unsent events in the buffer are dropped. This is intentional — sdk-garden-python prioritises zero impact on your SDK over delivery guarantees.
|
|
84
|
+
|
|
85
|
+
**Pass `disabled=True` in test environments to suppress all network calls.** When disabled, `start()` returns immediately without spawning a thread, and `track()` / `track_method()` / `track_error()` are all no-ops.
|
|
86
|
+
|
|
87
|
+
**HTTP requests use a 2-second timeout.** Slow or unreachable ingest endpoints are silently abandoned after 2 seconds.
|
|
88
|
+
|
|
89
|
+
**Queue overflow is silent.** The internal queue holds a maximum of 100 events. If your SDK generates events faster than the flush thread can drain them, new events are discarded without raising an exception.
|
|
90
|
+
|
|
91
|
+
## Notes
|
|
92
|
+
|
|
93
|
+
- `start()` must be called before events are flushed. Calling `track()` before `start()` is safe — events are queued and will be flushed once `start()` is called.
|
|
94
|
+
- `track_method()` returns a decorator. The decorated function behaves identically to the original; timing and error telemetry are captured transparently.
|
|
95
|
+
- Error messages passed to `track_error()` are PII-scrubbed: email addresses matching the standard `user@example.com` pattern are replaced with `[REDACTED]` before the event is enqueued.
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
MIT
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
sdk_garden/__init__.py,sha256=eV-sbncezEmHiLpv-mC9q7tlhD5U4E0PsWfvm98J7Mc,65
|
|
2
|
+
sdk_garden/client.py,sha256=JpK-kyjoqY1LE2LDjXZZ0zKvvRCoZMOaR969pXF2UF4,10019
|
|
3
|
+
sdk_garden_python-0.1.0.dist-info/METADATA,sha256=8JB8EU_oQIUrxENLnmYJoH7fvXC01Eorw6weTVVWbJk,4744
|
|
4
|
+
sdk_garden_python-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
5
|
+
sdk_garden_python-0.1.0.dist-info/RECORD,,
|