sdk-garden-python 0.1.0__tar.gz

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.
@@ -0,0 +1,7 @@
1
+ dist/
2
+ build/
3
+ *.egg-info/
4
+ __pycache__/
5
+ *.pyc
6
+ *.pyo
7
+ .pytest_cache/
@@ -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,89 @@
1
+ # sdk-garden-python
2
+ *Lightweight SDK observability client for Python — stdlib only, non-blocking*
3
+
4
+ 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.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pip install sdk-garden-python
10
+ ```
11
+
12
+ ## Quick Start
13
+
14
+ ```python
15
+ from sdk_garden import SdkGarden
16
+
17
+ sg = SdkGarden(api_key="YOUR_API_KEY", sdk_name="my-sdk", sdk_version="1.0.0").start()
18
+
19
+ @sg.track_method("process_payment")
20
+ def process_payment(amount):
21
+ ...
22
+ ```
23
+
24
+ Track arbitrary events:
25
+
26
+ ```python
27
+ sg.track("feature_used", properties={"feature": "streaming"})
28
+ ```
29
+
30
+ Track errors with automatic PII scrubbing:
31
+
32
+ ```python
33
+ try:
34
+ process_payment(100)
35
+ except Exception as e:
36
+ sg.track_error(e)
37
+ ```
38
+
39
+ ## Configuration
40
+
41
+ | Parameter | Type | Default | Description |
42
+ |-----------|------|---------|-------------|
43
+ | `api_key` | `str` | required | Your SDK Garden API key (`sgk_...`) |
44
+ | `sdk_name` | `str` | required | Name of your SDK (e.g. `"my-payments-sdk"`) |
45
+ | `sdk_version` | `str` | required | Version of your SDK (e.g. `"1.2.3"`) |
46
+ | `endpoint` | `str` | `https://ingest.sdkgarden.dev/track` | Ingest endpoint URL |
47
+ | `debug` | `bool` | `False` | Print `[SDK Garden]` debug lines to stdout |
48
+ | `disabled` | `bool` | `False` | Disable all tracking (no thread spawned, no network calls) |
49
+ | `batch_size` | `int` | `10` | Maximum events per HTTP POST |
50
+ | `flush_interval` | `float` | `5.0` | Seconds between flush cycles |
51
+ | `started_at` | `float` | import time | `time.time()` when your SDK began initializing; reported as `time_to_init_ms` for the dashboard's init timing |
52
+
53
+ ## What gets sent
54
+
55
+ 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:
56
+
57
+ - **`sdk_initialized`** (sent by `start()`): Python implementation and version, and `time_to_init_ms`.
58
+ - **`method_called` / `method_success`**: method name, duration, and the *number* of positional arguments.
59
+ - **`sdk_error`**: error type and message (email addresses are redacted) plus the method and duration.
60
+
61
+ Never sent: argument or return values, request or response bodies, stack traces, hostnames, environment variables or user identifiers. Nothing is sent when `disabled=True`.
62
+
63
+ ### Telling your users
64
+
65
+ Copy this into your SDK's README or privacy docs so your users know what's collected (edit the names to match):
66
+
67
+ > **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)*.
68
+
69
+ ## Behaviour Guarantees
70
+
71
+ **All network errors are caught and discarded. Your program will never see an exception from sdk-garden-python.**
72
+
73
+ **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.
74
+
75
+ **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.
76
+
77
+ **HTTP requests use a 2-second timeout.** Slow or unreachable ingest endpoints are silently abandoned after 2 seconds.
78
+
79
+ **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.
80
+
81
+ ## Notes
82
+
83
+ - `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.
84
+ - `track_method()` returns a decorator. The decorated function behaves identically to the original; timing and error telemetry are captured transparently.
85
+ - 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.
86
+
87
+ ## License
88
+
89
+ MIT
@@ -0,0 +1,21 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sdk-garden-python"
7
+ version = "0.1.0"
8
+ description = "Lightweight SDK observability client for Python — stdlib only, non-blocking"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+
13
+ [project.urls]
14
+ Homepage = "https://sdkgarden.dev"
15
+ Repository = "https://github.com/tjb/SDKGarden"
16
+
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["sdk_garden"]
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ pytest==9.0.3
@@ -0,0 +1,3 @@
1
+ from sdk_garden.client import SdkGarden
2
+
3
+ __all__ = ["SdkGarden"]
@@ -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())
File without changes
@@ -0,0 +1,210 @@
1
+ """
2
+ pytest unit tests for sdk-garden-python CLIENT-02 behaviors.
3
+
4
+ All tests use endpoint='http://localhost:9999/track' — never hits the network.
5
+ """
6
+ import queue
7
+ import socket
8
+ import threading
9
+ import time
10
+ from unittest.mock import patch, MagicMock, call
11
+
12
+ import pytest
13
+
14
+ from sdk_garden import SdkGarden
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Helpers
19
+ # ---------------------------------------------------------------------------
20
+
21
+ def make_sg(**kwargs):
22
+ """Factory for a fresh SdkGarden instance pointing at a dummy endpoint."""
23
+ defaults = dict(
24
+ api_key="sgk_testkey",
25
+ sdk_name="test-sdk",
26
+ sdk_version="1.0.0",
27
+ endpoint="http://localhost:9999/track",
28
+ )
29
+ defaults.update(kwargs)
30
+ return SdkGarden(**defaults)
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # class TestInit
35
+ # ---------------------------------------------------------------------------
36
+
37
+ class TestInit:
38
+ def test_start_spawns_daemon_thread(self):
39
+ sg = make_sg()
40
+ sg.start()
41
+ assert sg._thread is not None
42
+ assert sg._thread.daemon is True
43
+ assert sg._thread.is_alive() is True
44
+
45
+ def test_disabled_start_does_not_spawn_thread(self):
46
+ sg = make_sg(disabled=True)
47
+ sg.start()
48
+ assert sg._thread is None
49
+
50
+ def test_session_id_is_set(self):
51
+ sg = make_sg()
52
+ assert isinstance(sg._session_id, str)
53
+ assert len(sg._session_id) > 0
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # class TestTrack
58
+ # ---------------------------------------------------------------------------
59
+
60
+ class TestTrack:
61
+ def test_track_enqueues_event(self):
62
+ sg = make_sg()
63
+ before = sg._queue.qsize()
64
+ sg.track("sdk_initialized")
65
+ assert sg._queue.qsize() == before + 1
66
+
67
+ def test_track_event_shape(self):
68
+ sg = make_sg()
69
+ sg.track("sdk_initialized", {"key": "value"})
70
+ event = sg._queue.get_nowait()
71
+ assert "event" in event
72
+ assert "sdk" in event
73
+ assert "session_id" in event
74
+ assert "timestamp" in event
75
+ assert "properties" in event
76
+ assert "meta" in event
77
+ assert event["event"] == "sdk_initialized"
78
+ assert event["sdk"]["name"] == "test-sdk"
79
+ assert event["sdk"]["version"] == "1.0.0"
80
+
81
+ def test_track_drops_when_queue_full(self):
82
+ sg = make_sg()
83
+ # Fill the queue to maxsize=100
84
+ for _ in range(100):
85
+ sg._queue.put_nowait({"event": "dummy"})
86
+ # This must NOT raise and queue size must stay at 100
87
+ sg.track("sdk_initialized")
88
+ assert sg._queue.qsize() == 100
89
+
90
+ def test_track_disabled_noop(self):
91
+ sg = make_sg(disabled=True)
92
+ sg.track("sdk_initialized")
93
+ assert sg._queue.qsize() == 0
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # class TestFlushBatch
98
+ # ---------------------------------------------------------------------------
99
+
100
+ class TestFlushBatch:
101
+ def test_flush_batch_posts_events(self):
102
+ sg = make_sg()
103
+ sg.track("sdk_initialized")
104
+ with patch("urllib.request.urlopen") as mock_urlopen:
105
+ mock_urlopen.return_value = MagicMock(status=200)
106
+ sg._flush_batch()
107
+ assert mock_urlopen.called
108
+
109
+ def test_flush_batch_timeout_is_2_seconds(self):
110
+ sg = make_sg()
111
+ sg.track("sdk_initialized")
112
+ with patch("urllib.request.urlopen") as mock_urlopen:
113
+ mock_urlopen.return_value = MagicMock(status=200)
114
+ sg._flush_batch()
115
+ # Verify the timeout keyword argument is 2
116
+ args, kwargs = mock_urlopen.call_args
117
+ assert kwargs.get("timeout") == 2
118
+
119
+ def test_flush_batch_silent_on_network_error(self):
120
+ sg = make_sg()
121
+ sg.track("sdk_initialized")
122
+ with patch("urllib.request.urlopen", side_effect=Exception("network down")):
123
+ # Must NOT raise
124
+ sg._flush_batch()
125
+
126
+ def test_flush_batch_silent_on_timeout_error(self):
127
+ sg = make_sg()
128
+ sg.track("sdk_initialized")
129
+ with patch("urllib.request.urlopen", side_effect=socket.timeout("timed out")):
130
+ # Must NOT raise
131
+ sg._flush_batch()
132
+
133
+ def test_flush_batch_noop_when_empty(self):
134
+ sg = make_sg()
135
+ with patch("urllib.request.urlopen") as mock_urlopen:
136
+ sg._flush_batch()
137
+ mock_urlopen.assert_not_called()
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # class TestPIIScrub
142
+ # ---------------------------------------------------------------------------
143
+
144
+ class TestPIIScrub:
145
+ def test_scrub_pii_in_error_message(self):
146
+ sg = make_sg()
147
+ error = Exception("Failed for user@example.com at login")
148
+ sg.track_error(error)
149
+ event = sg._queue.get_nowait()
150
+ error_msg = event["properties"]["error_message"]
151
+ assert "user@example.com" not in error_msg
152
+ assert "[REDACTED]" in error_msg
153
+
154
+ def test_scrub_pii_non_string_passthrough(self):
155
+ sg = make_sg()
156
+ # _scrub_pii should return non-strings unchanged
157
+ assert sg._scrub_pii(42) == 42
158
+ assert sg._scrub_pii(None) is None
159
+
160
+ def test_scrub_pii_removes_email(self):
161
+ sg = make_sg()
162
+ result = sg._scrub_pii("Contact admin@corp.io for support")
163
+ assert "admin@corp.io" not in result
164
+ assert "[REDACTED]" in result
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # class TestStartupEvent
169
+ # ---------------------------------------------------------------------------
170
+
171
+ class TestStartupEvent:
172
+ @pytest.fixture(autouse=True)
173
+ def _no_flush_thread(self):
174
+ # Keep events in the queue so the test can inspect them.
175
+ with patch.object(SdkGarden, "_flush_loop", lambda self: None):
176
+ yield
177
+
178
+ def _drain(self, sg):
179
+ events = []
180
+ while not sg._queue.empty():
181
+ events.append(sg._queue.get_nowait())
182
+ return events
183
+
184
+ def test_start_sends_sdk_initialized_with_environment_and_timing(self):
185
+ sg = make_sg(started_at=time.time() - 0.25, flush_interval=3600)
186
+ sg.start()
187
+ events = self._drain(sg)
188
+ assert [e["event"] for e in events] == ["sdk_initialized"]
189
+ props = events[0]["properties"]
190
+ assert props["environment"]["runtime"] == "cpython"
191
+ assert props["environment"]["runtime_version"].count(".") == 2
192
+ assert 250 <= props["time_to_init_ms"] < 5000
193
+
194
+ def test_start_twice_and_manual_track_do_not_double_count(self):
195
+ sg = make_sg(flush_interval=3600)
196
+ sg.start()
197
+ sg.start()
198
+ sg.track("sdk_initialized")
199
+ events = self._drain(sg)
200
+ assert [e["event"] for e in events] == ["sdk_initialized"]
201
+
202
+ def test_time_to_init_is_never_negative(self):
203
+ sg = make_sg(started_at=time.time() + 60, flush_interval=3600)
204
+ sg.start()
205
+ assert self._drain(sg)[0]["properties"]["time_to_init_ms"] == 0
206
+
207
+ def test_disabled_start_sends_nothing(self):
208
+ sg = make_sg(disabled=True)
209
+ sg.start()
210
+ assert sg._queue.empty()