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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sdk-garden-python
3
- Version: 0.1.1
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 `time_to_init_ms`.
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 `time_to_init_ms`.
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sdk-garden-python"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Lightweight SDK observability client for Python — stdlib only, non-blocking"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -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