sdk-garden-python 0.1.1__tar.gz → 0.2.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.
- {sdk_garden_python-0.1.1 → sdk_garden_python-0.2.0}/PKG-INFO +23 -2
- {sdk_garden_python-0.1.1 → sdk_garden_python-0.2.0}/README.md +22 -1
- {sdk_garden_python-0.1.1 → sdk_garden_python-0.2.0}/pyproject.toml +1 -1
- {sdk_garden_python-0.1.1 → sdk_garden_python-0.2.0}/sdk_garden/client.py +176 -13
- {sdk_garden_python-0.1.1 → sdk_garden_python-0.2.0}/tests/test_client.py +126 -0
- {sdk_garden_python-0.1.1 → sdk_garden_python-0.2.0}/.gitignore +0 -0
- {sdk_garden_python-0.1.1 → sdk_garden_python-0.2.0}/requirements-dev.txt +0 -0
- {sdk_garden_python-0.1.1 → sdk_garden_python-0.2.0}/sdk_garden/__init__.py +0 -0
- {sdk_garden_python-0.1.1 → sdk_garden_python-0.2.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sdk-garden-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Lightweight SDK observability client for Python — stdlib only, non-blocking
|
|
5
5
|
Project-URL: Homepage, https://sdkgarden.com
|
|
6
6
|
Project-URL: Repository, https://github.com/tjb/SDKGarden
|
|
@@ -59,14 +59,35 @@ except Exception as e:
|
|
|
59
59
|
| `batch_size` | `int` | `10` | Maximum events per HTTP POST, from 1 to 100 |
|
|
60
60
|
| `flush_interval` | `float` | `5.0` | Seconds between flush cycles, from 0.1 to 86400 (a full batch is sent sooner) |
|
|
61
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
|
+
| `methods` | iterable of `str` | `None` | Public methods of your SDK that are not wrapped with `track_method()`, so the dashboard's API Surface panel can show them when nobody calls them |
|
|
63
|
+
| `customer_id` / `customer_name` | `str` | `None` | The app or customer account this client runs for (see [Customers](#customers)) |
|
|
64
|
+
|
|
65
|
+
### API surface
|
|
66
|
+
|
|
67
|
+
Every name passed to `track_method()` is declared as part of your SDK's API surface, once per client (in the `sdk_initialized` event, or in one `api_surface` event for methods decorated after `start()`). The dashboard lists those methods next to the ones people call, so the methods nobody uses show up with zero calls. Add methods you don't decorate with the `methods` parameter. Up to 500 names are declared.
|
|
68
|
+
|
|
69
|
+
### Customers
|
|
70
|
+
|
|
71
|
+
Tell SDK Garden which of your customers a client belongs to, and the dashboard's Customers page shows who is on an old version, who still calls a deprecated method and who hits errors, so you know who to contact.
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
# When your SDK knows which account it runs for, for example after resolving its API key:
|
|
75
|
+
sg.set_customer(account.id, name=account.display_name)
|
|
76
|
+
|
|
77
|
+
# Or when creating the client, if you already know it:
|
|
78
|
+
sg = SdkGarden(api_key="sgk_...", sdk_name="my-sdk", sdk_version="1.0.0", customer_id="acct_123").start()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Use an ID from your own system, such as an account, project or app ID, and optionally a display name. Nothing is attributed unless you set one. Don't pass email addresses or other personal data: an ID that contains an email address is ignored, and so is a name that contains one. IDs are cut to 128 characters and names to 200. `set_customer(None)` stops attributing.
|
|
62
82
|
|
|
63
83
|
## What gets sent
|
|
64
84
|
|
|
65
85
|
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
86
|
|
|
67
|
-
- **`sdk_initialized`** (sent by `start()`): Python implementation and version, and
|
|
87
|
+
- **`sdk_initialized`** (sent by `start()`): Python implementation and version, `time_to_init_ms`, and the names of your SDK's methods (see [API surface](#api-surface)).
|
|
68
88
|
- **`method_called` / `method_success`**: method name, duration, and the *number* of positional arguments.
|
|
69
89
|
- **`sdk_error`**: error type and message (email addresses are redacted) plus the method and duration.
|
|
90
|
+
- **If you set a customer**, every event carries its ID and name (see [Customers](#customers)).
|
|
70
91
|
|
|
71
92
|
Never sent: argument or return values, request or response bodies, stack traces or environment variables. Error messages go out as they are apart from email addresses, so anything your SDK puts in an exception message, such as a hostname, a URL or an ID, goes with it. Nothing is sent when `disabled=True`.
|
|
72
93
|
|
|
@@ -49,14 +49,35 @@ except Exception as e:
|
|
|
49
49
|
| `batch_size` | `int` | `10` | Maximum events per HTTP POST, from 1 to 100 |
|
|
50
50
|
| `flush_interval` | `float` | `5.0` | Seconds between flush cycles, from 0.1 to 86400 (a full batch is sent sooner) |
|
|
51
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
|
+
| `methods` | iterable of `str` | `None` | Public methods of your SDK that are not wrapped with `track_method()`, so the dashboard's API Surface panel can show them when nobody calls them |
|
|
53
|
+
| `customer_id` / `customer_name` | `str` | `None` | The app or customer account this client runs for (see [Customers](#customers)) |
|
|
54
|
+
|
|
55
|
+
### API surface
|
|
56
|
+
|
|
57
|
+
Every name passed to `track_method()` is declared as part of your SDK's API surface, once per client (in the `sdk_initialized` event, or in one `api_surface` event for methods decorated after `start()`). The dashboard lists those methods next to the ones people call, so the methods nobody uses show up with zero calls. Add methods you don't decorate with the `methods` parameter. Up to 500 names are declared.
|
|
58
|
+
|
|
59
|
+
### Customers
|
|
60
|
+
|
|
61
|
+
Tell SDK Garden which of your customers a client belongs to, and the dashboard's Customers page shows who is on an old version, who still calls a deprecated method and who hits errors, so you know who to contact.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
# When your SDK knows which account it runs for, for example after resolving its API key:
|
|
65
|
+
sg.set_customer(account.id, name=account.display_name)
|
|
66
|
+
|
|
67
|
+
# Or when creating the client, if you already know it:
|
|
68
|
+
sg = SdkGarden(api_key="sgk_...", sdk_name="my-sdk", sdk_version="1.0.0", customer_id="acct_123").start()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Use an ID from your own system, such as an account, project or app ID, and optionally a display name. Nothing is attributed unless you set one. Don't pass email addresses or other personal data: an ID that contains an email address is ignored, and so is a name that contains one. IDs are cut to 128 characters and names to 200. `set_customer(None)` stops attributing.
|
|
52
72
|
|
|
53
73
|
## What gets sent
|
|
54
74
|
|
|
55
75
|
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
76
|
|
|
57
|
-
- **`sdk_initialized`** (sent by `start()`): Python implementation and version, and
|
|
77
|
+
- **`sdk_initialized`** (sent by `start()`): Python implementation and version, `time_to_init_ms`, and the names of your SDK's methods (see [API surface](#api-surface)).
|
|
58
78
|
- **`method_called` / `method_success`**: method name, duration, and the *number* of positional arguments.
|
|
59
79
|
- **`sdk_error`**: error type and message (email addresses are redacted) plus the method and duration.
|
|
80
|
+
- **If you set a customer**, every event carries its ID and name (see [Customers](#customers)).
|
|
60
81
|
|
|
61
82
|
Never sent: argument or return values, request or response bodies, stack traces or environment variables. Error messages go out as they are apart from email addresses, so anything your SDK puts in an exception message, such as a hostname, a URL or an ID, goes with it. Nothing is sent when `disabled=True`.
|
|
62
83
|
|
|
@@ -31,7 +31,7 @@ import urllib.error
|
|
|
31
31
|
import urllib.request
|
|
32
32
|
import uuid
|
|
33
33
|
import weakref
|
|
34
|
-
from typing import Any, Callable, Dict, List, Optional, Set, TypeVar
|
|
34
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, TypeVar
|
|
35
35
|
|
|
36
36
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
37
37
|
|
|
@@ -80,6 +80,23 @@ _MAX_EMAIL_LENGTH = 254
|
|
|
80
80
|
#: The ingest endpoint stores event names of at most this many characters.
|
|
81
81
|
_MAX_EVENT_NAME_LENGTH = 100
|
|
82
82
|
|
|
83
|
+
#: Longest customer ID and name sent, in characters (the server's column sizes).
|
|
84
|
+
_MAX_CUSTOMER_ID_LENGTH = 128
|
|
85
|
+
_MAX_CUSTOMER_NAME_LENGTH = 200
|
|
86
|
+
|
|
87
|
+
#: Control characters, removed from customer IDs and names.
|
|
88
|
+
_CONTROL_CHARS = re.compile("[\x00-\x1f\x7f-\x9f]")
|
|
89
|
+
|
|
90
|
+
#: Most method names one API surface declaration carries.
|
|
91
|
+
_MAX_SURFACE_METHODS = 500
|
|
92
|
+
|
|
93
|
+
#: Longest method name the endpoint stores.
|
|
94
|
+
_MAX_METHOD_NAME_LENGTH = 255
|
|
95
|
+
|
|
96
|
+
#: Most characters of method names in one declaration, so its event stays
|
|
97
|
+
#: well under _MAX_EVENT_BYTES.
|
|
98
|
+
_MAX_SURFACE_CHARS = 32 * 1024
|
|
99
|
+
|
|
83
100
|
#: Characters the ingest endpoint cannot store: NUL and surrogates.
|
|
84
101
|
_UNSTORABLE_CHARS = re.compile("[\x00\ud800-\udfff]")
|
|
85
102
|
|
|
@@ -234,6 +251,52 @@ def _sanitize_json(serialized: str) -> str:
|
|
|
234
251
|
return _JSON_ESCAPE.sub(replace, serialized)
|
|
235
252
|
|
|
236
253
|
|
|
254
|
+
def _is_method_name(name: Any) -> bool:
|
|
255
|
+
"""Whether name can be stored as a method name."""
|
|
256
|
+
return isinstance(name, str) and name.strip() != "" and len(name) <= _MAX_METHOD_NAME_LENGTH
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _surface_list(names: Iterable[Any]) -> List[str]:
|
|
260
|
+
"""Up to _MAX_SURFACE_METHODS distinct method names, at most
|
|
261
|
+
_MAX_SURFACE_CHARS in all, in the order given."""
|
|
262
|
+
out: List[str] = []
|
|
263
|
+
seen: Set[str] = set()
|
|
264
|
+
chars = 0
|
|
265
|
+
for name in names:
|
|
266
|
+
if len(out) >= _MAX_SURFACE_METHODS:
|
|
267
|
+
break
|
|
268
|
+
if not _is_method_name(name) or name in seen:
|
|
269
|
+
continue
|
|
270
|
+
if chars + len(name) > _MAX_SURFACE_CHARS:
|
|
271
|
+
break
|
|
272
|
+
out.append(name)
|
|
273
|
+
seen.add(name)
|
|
274
|
+
chars += len(name)
|
|
275
|
+
return out
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _customer_field(value: Any, max_length: int) -> str:
|
|
279
|
+
"""A customer field trimmed, without control characters, cut to max_length."""
|
|
280
|
+
if not isinstance(value, str):
|
|
281
|
+
return ""
|
|
282
|
+
return _CONTROL_CHARS.sub("", value).strip()[:max_length].strip()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _clean_customer(customer_id: Any, name: Any) -> Optional[Dict[str, str]]:
|
|
286
|
+
"""The customer to send, or None.
|
|
287
|
+
|
|
288
|
+
An ID that contains an email address is refused, and a name that contains
|
|
289
|
+
one is left out: attribution must not carry personal data.
|
|
290
|
+
"""
|
|
291
|
+
cid = _customer_field(customer_id, _MAX_CUSTOMER_ID_LENGTH)
|
|
292
|
+
if not cid or _PII_PATTERN.search(cid):
|
|
293
|
+
return None
|
|
294
|
+
cname = _customer_field(name, _MAX_CUSTOMER_NAME_LENGTH)
|
|
295
|
+
if cname and not _PII_PATTERN.search(cname):
|
|
296
|
+
return {"id": cid, "name": cname}
|
|
297
|
+
return {"id": cid}
|
|
298
|
+
|
|
299
|
+
|
|
237
300
|
def _scrub_error_message(message: str) -> str:
|
|
238
301
|
"""The part of an error message the endpoint can keep, with email
|
|
239
302
|
addresses redacted. Only the first _MAX_ERROR_MESSAGE_BYTES characters
|
|
@@ -286,12 +349,22 @@ class SdkGarden:
|
|
|
286
349
|
batch_size: int = _BATCH_SIZE,
|
|
287
350
|
flush_interval: float = _FLUSH_INTERVAL,
|
|
288
351
|
started_at: Optional[float] = None,
|
|
352
|
+
methods: Optional[Iterable[str]] = None,
|
|
353
|
+
customer_id: Optional[str] = None,
|
|
354
|
+
customer_name: Optional[str] = None,
|
|
289
355
|
) -> None:
|
|
290
356
|
"""Create a client. Nothing is sent until ``start()``.
|
|
291
357
|
|
|
292
358
|
``started_at`` is when your SDK began initializing, as ``time.time()``;
|
|
293
359
|
``start()`` reports the difference as ``time_to_init_ms``. Defaults to
|
|
294
360
|
when sdk_garden was imported.
|
|
361
|
+
|
|
362
|
+
``methods`` names public methods of your SDK that are not wrapped with
|
|
363
|
+
``track_method()``. ``start()`` sends them with every name passed to
|
|
364
|
+
``track_method()``, so the dashboard can list the methods nobody calls.
|
|
365
|
+
|
|
366
|
+
``customer_id`` and ``customer_name`` attribute the session to one of
|
|
367
|
+
your customers from the start; see ``set_customer()``.
|
|
295
368
|
"""
|
|
296
369
|
self._api_key = api_key
|
|
297
370
|
self._sdk_name = sdk_name
|
|
@@ -303,6 +376,29 @@ class SdkGarden:
|
|
|
303
376
|
self._flush_interval = _clamp_flush_interval(flush_interval)
|
|
304
377
|
self._started_at = _start_time(started_at)
|
|
305
378
|
|
|
379
|
+
#: The customer added to every event, if set (see set_customer()).
|
|
380
|
+
#: Replaced whole, never changed in place, so readers need no lock.
|
|
381
|
+
self._customer: Optional[Dict[str, str]] = None
|
|
382
|
+
if customer_id is not None:
|
|
383
|
+
self._customer = _clean_customer(customer_id, customer_name)
|
|
384
|
+
if self._customer is None:
|
|
385
|
+
self._log("ignored a customer id that is empty or contains an email address")
|
|
386
|
+
|
|
387
|
+
#: Method names from ``methods`` and track_method(), in order.
|
|
388
|
+
self._registered: List[str] = []
|
|
389
|
+
try:
|
|
390
|
+
self._registered = _surface_list(methods or ())
|
|
391
|
+
except Exception:
|
|
392
|
+
pass # Not an iterable of names: declare only what track_method() registers.
|
|
393
|
+
|
|
394
|
+
#: Names registered after start() that the flush thread has yet to
|
|
395
|
+
#: declare in an api_surface event. Guarded by _surface_lock.
|
|
396
|
+
self._undeclared: List[str] = []
|
|
397
|
+
self._surface_lock = threading.RLock()
|
|
398
|
+
|
|
399
|
+
#: True once start() has declared the methods registered so far.
|
|
400
|
+
self._surface_sent = False
|
|
401
|
+
|
|
306
402
|
#: True once start() has sent sdk_initialized for this session.
|
|
307
403
|
self._init_sent = False
|
|
308
404
|
|
|
@@ -380,16 +476,36 @@ class SdkGarden:
|
|
|
380
476
|
if not self._init_sent:
|
|
381
477
|
self.track(
|
|
382
478
|
"sdk_initialized",
|
|
383
|
-
|
|
384
|
-
"environment": self._get_environment(),
|
|
385
|
-
"time_to_init_ms": self._time_to_init_ms(),
|
|
386
|
-
},
|
|
479
|
+
self._init_properties(),
|
|
387
480
|
)
|
|
388
481
|
self._init_sent = True
|
|
389
482
|
_register_live(self)
|
|
390
483
|
self._ensure_thread()
|
|
391
484
|
return self
|
|
392
485
|
|
|
486
|
+
def set_customer(self, customer_id: Optional[str], name: Optional[str] = None) -> None:
|
|
487
|
+
"""Attribute events tracked from now on to one of your customers.
|
|
488
|
+
|
|
489
|
+
Pass an ID from your own system, such as an account, project or app ID,
|
|
490
|
+
and optionally a display name. The dashboard then shows which
|
|
491
|
+
customers are on old versions, still call deprecated methods or hit
|
|
492
|
+
errors. ``set_customer(None)`` stops attributing.
|
|
493
|
+
|
|
494
|
+
Never pass an email address or other personal data: an ID containing
|
|
495
|
+
one is ignored, and so is a name containing one. IDs are cut to 128
|
|
496
|
+
characters and names to 200. Never raises.
|
|
497
|
+
"""
|
|
498
|
+
try:
|
|
499
|
+
if customer_id is None:
|
|
500
|
+
self._customer = None
|
|
501
|
+
return
|
|
502
|
+
customer = _clean_customer(customer_id, name)
|
|
503
|
+
if customer is None:
|
|
504
|
+
self._log("ignored a customer id that is empty or contains an email address")
|
|
505
|
+
self._customer = customer
|
|
506
|
+
except Exception:
|
|
507
|
+
pass # Instrumentation must never raise into the host.
|
|
508
|
+
|
|
393
509
|
def track(
|
|
394
510
|
self,
|
|
395
511
|
event_name: str,
|
|
@@ -413,16 +529,20 @@ class SdkGarden:
|
|
|
413
529
|
# start() already reported it; a manual call (as older docs
|
|
414
530
|
# suggested) would double-count the session.
|
|
415
531
|
return
|
|
532
|
+
event: Dict[str, Any] = {
|
|
533
|
+
"event": event_name,
|
|
534
|
+
"sdk": {"name": self._sdk_name, "version": self._sdk_version},
|
|
535
|
+
"session_id": self._session_id,
|
|
536
|
+
"timestamp": int(time.time() * 1000),
|
|
537
|
+
"properties": properties or {},
|
|
538
|
+
"meta": {},
|
|
539
|
+
}
|
|
540
|
+
customer = self._customer
|
|
541
|
+
if customer is not None:
|
|
542
|
+
event["customer"] = customer
|
|
416
543
|
try:
|
|
417
544
|
serialized = json.dumps(
|
|
418
|
-
|
|
419
|
-
"event": event_name,
|
|
420
|
-
"sdk": {"name": self._sdk_name, "version": self._sdk_version},
|
|
421
|
-
"session_id": self._session_id,
|
|
422
|
-
"timestamp": int(time.time() * 1000),
|
|
423
|
-
"properties": properties or {},
|
|
424
|
-
"meta": {},
|
|
425
|
-
},
|
|
545
|
+
event,
|
|
426
546
|
default=str,
|
|
427
547
|
allow_nan=False,
|
|
428
548
|
)
|
|
@@ -464,6 +584,8 @@ class SdkGarden:
|
|
|
464
584
|
|
|
465
585
|
return _noop_decorator # type: ignore[return-value]
|
|
466
586
|
|
|
587
|
+
self._register(method_name)
|
|
588
|
+
|
|
467
589
|
def decorator(fn: F) -> F:
|
|
468
590
|
if inspect.iscoroutinefunction(fn):
|
|
469
591
|
@functools.wraps(fn)
|
|
@@ -712,6 +834,43 @@ class SdkGarden:
|
|
|
712
834
|
except RuntimeError:
|
|
713
835
|
pass # One is already pending
|
|
714
836
|
|
|
837
|
+
def _init_properties(self) -> Dict[str, Any]:
|
|
838
|
+
"""Properties of the sdk_initialized event, with the API surface
|
|
839
|
+
registered so far."""
|
|
840
|
+
properties: Dict[str, Any] = {
|
|
841
|
+
"environment": self._get_environment(),
|
|
842
|
+
"time_to_init_ms": self._time_to_init_ms(),
|
|
843
|
+
}
|
|
844
|
+
with self._surface_lock:
|
|
845
|
+
surface = _surface_list(self._registered)
|
|
846
|
+
self._undeclared = []
|
|
847
|
+
self._surface_sent = True
|
|
848
|
+
if surface:
|
|
849
|
+
properties["api_surface"] = surface
|
|
850
|
+
return properties
|
|
851
|
+
|
|
852
|
+
def _register(self, method_name: str) -> None:
|
|
853
|
+
"""Remember a method passed to track_method(). One registered after
|
|
854
|
+
start() is declared by the flush thread in an api_surface event, with
|
|
855
|
+
any others registered by then (see _declare_undeclared)."""
|
|
856
|
+
if not _is_method_name(method_name):
|
|
857
|
+
return
|
|
858
|
+
with self._surface_lock:
|
|
859
|
+
if method_name in self._registered or len(self._registered) >= _MAX_SURFACE_METHODS:
|
|
860
|
+
return
|
|
861
|
+
self._registered.append(method_name)
|
|
862
|
+
if self._surface_sent:
|
|
863
|
+
self._undeclared.append(method_name)
|
|
864
|
+
|
|
865
|
+
def _declare_undeclared(self) -> None:
|
|
866
|
+
"""Queue an api_surface event for the methods registered since the
|
|
867
|
+
last declaration. Runs on the flush thread."""
|
|
868
|
+
with self._surface_lock:
|
|
869
|
+
names, self._undeclared = self._undeclared, []
|
|
870
|
+
surface = _surface_list(names)
|
|
871
|
+
if surface:
|
|
872
|
+
self.track("api_surface", {"api_surface": surface})
|
|
873
|
+
|
|
715
874
|
def _time_to_init_ms(self) -> int:
|
|
716
875
|
"""Milliseconds since the host SDK began initializing (see started_at)."""
|
|
717
876
|
try:
|
|
@@ -754,6 +913,10 @@ class SdkGarden:
|
|
|
754
913
|
def _drain(self) -> None:
|
|
755
914
|
"""Send what is queued now, a batch at a time. Stops at the first
|
|
756
915
|
failed request: the rest waits for the next cycle."""
|
|
916
|
+
try:
|
|
917
|
+
self._declare_undeclared()
|
|
918
|
+
except Exception:
|
|
919
|
+
pass # Declaring methods must never stop events from being sent.
|
|
757
920
|
remaining = len(self._queue)
|
|
758
921
|
while remaining > 0:
|
|
759
922
|
events = self._take_batch(min(remaining, self._batch_size))
|
|
@@ -1109,3 +1109,129 @@ class TestSignalHandlers:
|
|
|
1109
1109
|
proc, _elapsed = run_script(INTERVAL_UNDER_SIGNALS_SCRIPT, timeout=20)
|
|
1110
1110
|
assert proc.returncode == 0, proc.stderr
|
|
1111
1111
|
assert float(proc.stdout) < 1.0 # worst delay from track() to send, in seconds
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
# ---------------------------------------------------------------------------
|
|
1115
|
+
# class TestAPISurface
|
|
1116
|
+
# ---------------------------------------------------------------------------
|
|
1117
|
+
|
|
1118
|
+
class TestAPISurface:
|
|
1119
|
+
def test_start_declares_registered_methods_and_methods_param(self, urlopen):
|
|
1120
|
+
sg = make_sg(flush_interval=3600, methods=["webhooks.verify", "charges.create", "", 42, "x" * 256])
|
|
1121
|
+
|
|
1122
|
+
@sg.track_method("charges.create")
|
|
1123
|
+
def create():
|
|
1124
|
+
return 1
|
|
1125
|
+
|
|
1126
|
+
@sg.track_method("refunds.create")
|
|
1127
|
+
def refund():
|
|
1128
|
+
return 1
|
|
1129
|
+
|
|
1130
|
+
sg.start()
|
|
1131
|
+
sg.flush()
|
|
1132
|
+
init = [e for batch in sent_batches(urlopen) for e in batch if e["event"] == "sdk_initialized"][0]
|
|
1133
|
+
assert init["properties"]["api_surface"] == ["webhooks.verify", "charges.create", "refunds.create"]
|
|
1134
|
+
assert "api_surface" not in sent_events(urlopen)
|
|
1135
|
+
|
|
1136
|
+
def test_no_api_surface_when_nothing_is_registered(self, urlopen):
|
|
1137
|
+
sg = make_started()
|
|
1138
|
+
sg.flush()
|
|
1139
|
+
assert "api_surface" not in sent_batches(urlopen)[0][0]["properties"]
|
|
1140
|
+
|
|
1141
|
+
def test_methods_registered_after_start_are_declared_together(self, urlopen):
|
|
1142
|
+
sg = make_started()
|
|
1143
|
+
for name in ["a", "b", "a"]:
|
|
1144
|
+
sg.track_method(name)(lambda: 1)
|
|
1145
|
+
sg.flush()
|
|
1146
|
+
declared = [e for batch in sent_batches(urlopen) for e in batch if e["event"] == "api_surface"]
|
|
1147
|
+
assert len(declared) == 1
|
|
1148
|
+
assert declared[0]["properties"]["api_surface"] == ["a", "b"]
|
|
1149
|
+
sg.flush()
|
|
1150
|
+
assert sent_events(urlopen).count("api_surface") == 1
|
|
1151
|
+
|
|
1152
|
+
def test_a_disabled_client_registers_nothing(self):
|
|
1153
|
+
sg = make_sg(disabled=True)
|
|
1154
|
+
sg.track_method("a")(lambda: 1)
|
|
1155
|
+
assert sg._registered == []
|
|
1156
|
+
|
|
1157
|
+
def test_the_declaration_stays_small_enough_to_send(self, urlopen):
|
|
1158
|
+
names = ["method_%d_%s" % (i, "x" * 200) for i in range(600)]
|
|
1159
|
+
sg = make_sg(flush_interval=3600, methods=names).start()
|
|
1160
|
+
sg.flush()
|
|
1161
|
+
init = sent_batches(urlopen)[0][0]
|
|
1162
|
+
assert len(init["properties"]["api_surface"]) > 100
|
|
1163
|
+
assert len(json.dumps(init)) < 40 * 1024
|
|
1164
|
+
|
|
1165
|
+
def test_methods_that_are_not_a_list_never_break_the_client(self, urlopen):
|
|
1166
|
+
sg = make_sg(flush_interval=3600, methods=7).start()
|
|
1167
|
+
sg.flush()
|
|
1168
|
+
assert sent_events(urlopen) == ["sdk_initialized"]
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
# ---------------------------------------------------------------------------
|
|
1172
|
+
# class TestCustomer
|
|
1173
|
+
# ---------------------------------------------------------------------------
|
|
1174
|
+
|
|
1175
|
+
def queued_events(sg):
|
|
1176
|
+
return [json.loads(e) for e in sg._queue]
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
class TestCustomer:
|
|
1180
|
+
def test_no_customer_by_default(self):
|
|
1181
|
+
sg = make_sg()
|
|
1182
|
+
sg.track("custom_event")
|
|
1183
|
+
assert "customer" not in queued_events(sg)[0]
|
|
1184
|
+
|
|
1185
|
+
def test_constructor_customer_on_every_event(self):
|
|
1186
|
+
sg = make_sg(customer_id="acct_123", customer_name="Acme Corp")
|
|
1187
|
+
sg.track("custom_event")
|
|
1188
|
+
sg.track_error(ValueError("boom"))
|
|
1189
|
+
for event in queued_events(sg):
|
|
1190
|
+
assert event["customer"] == {"id": "acct_123", "name": "Acme Corp"}
|
|
1191
|
+
|
|
1192
|
+
def test_set_customer_applies_to_later_events_and_none_stops(self):
|
|
1193
|
+
sg = make_sg()
|
|
1194
|
+
sg.track("before")
|
|
1195
|
+
sg.set_customer(" acct_9\n")
|
|
1196
|
+
sg.track("during")
|
|
1197
|
+
sg.set_customer(None)
|
|
1198
|
+
sg.track("after")
|
|
1199
|
+
by_name = {e["event"]: e for e in queued_events(sg)}
|
|
1200
|
+
assert "customer" not in by_name["before"]
|
|
1201
|
+
assert by_name["during"]["customer"] == {"id": "acct_9"}
|
|
1202
|
+
assert "customer" not in by_name["after"]
|
|
1203
|
+
|
|
1204
|
+
def test_wrapped_calls_carry_customer(self):
|
|
1205
|
+
sg = make_sg()
|
|
1206
|
+
sg.set_customer("acct_1")
|
|
1207
|
+
|
|
1208
|
+
@sg.track_method("charge")
|
|
1209
|
+
def charge():
|
|
1210
|
+
return 1
|
|
1211
|
+
|
|
1212
|
+
charge()
|
|
1213
|
+
events = [e for e in queued_events(sg) if e["event"].startswith("method_")]
|
|
1214
|
+
assert len(events) == 2
|
|
1215
|
+
assert all(e["customer"] == {"id": "acct_1"} for e in events)
|
|
1216
|
+
|
|
1217
|
+
def test_email_id_ignored_and_email_name_left_out(self):
|
|
1218
|
+
sg = make_sg()
|
|
1219
|
+
sg.set_customer("jane@acme.com", "Acme")
|
|
1220
|
+
sg.track("email_id")
|
|
1221
|
+
sg.set_customer("acct_2", "Jane <jane@acme.com>")
|
|
1222
|
+
sg.track("email_name")
|
|
1223
|
+
by_name = {e["event"]: e for e in queued_events(sg)}
|
|
1224
|
+
assert "customer" not in by_name["email_id"]
|
|
1225
|
+
assert by_name["email_name"]["customer"] == {"id": "acct_2"}
|
|
1226
|
+
|
|
1227
|
+
def test_invalid_values_never_raise_and_long_values_are_cut(self):
|
|
1228
|
+
sg = make_sg(customer_id="")
|
|
1229
|
+
sg.set_customer(42)
|
|
1230
|
+
sg.set_customer(object(), name=["x"])
|
|
1231
|
+
sg.track("invalid")
|
|
1232
|
+
sg.set_customer("a" * 300, "n" * 300)
|
|
1233
|
+
sg.track("long")
|
|
1234
|
+
by_name = {e["event"]: e for e in queued_events(sg)}
|
|
1235
|
+
assert "customer" not in by_name["invalid"]
|
|
1236
|
+
assert len(by_name["long"]["customer"]["id"]) == 128
|
|
1237
|
+
assert len(by_name["long"]["customer"]["name"]) == 200
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|