debugbundle-python 0.1.2__tar.gz → 0.1.5__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.
Files changed (38) hide show
  1. {debugbundle_python-0.1.2/src/debugbundle_python.egg-info → debugbundle_python-0.1.5}/PKG-INFO +1 -1
  2. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/pyproject.toml +1 -1
  3. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/config.py +24 -0
  4. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/core.py +39 -2
  5. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5/src/debugbundle_python.egg-info}/PKG-INFO +1 -1
  6. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/tests/test_remote_config.py +131 -1
  7. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/LICENSE +0 -0
  8. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/README.md +0 -0
  9. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/setup.cfg +0 -0
  10. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/__init__.py +0 -0
  11. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/integrations/__init__.py +0 -0
  12. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/integrations/common.py +0 -0
  13. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/integrations/django.py +0 -0
  14. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/integrations/fastapi.py +0 -0
  15. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/integrations/flask.py +0 -0
  16. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/integrations/relay_django.py +0 -0
  17. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/integrations/relay_fastapi.py +0 -0
  18. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/integrations/relay_flask.py +0 -0
  19. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/logger_integrations.py +0 -0
  20. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/py.typed +0 -0
  21. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/redaction.py +0 -0
  22. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/relay.py +0 -0
  23. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/suppression.py +0 -0
  24. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/transport.py +0 -0
  25. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle/trigger_token.py +0 -0
  26. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle_python.egg-info/SOURCES.txt +0 -0
  27. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle_python.egg-info/dependency_links.txt +0 -0
  28. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle_python.egg-info/requires.txt +0 -0
  29. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/src/debugbundle_python.egg-info/top_level.txt +0 -0
  30. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/tests/test_contracts.py +0 -0
  31. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/tests/test_framework_integrations.py +0 -0
  32. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/tests/test_hooks.py +0 -0
  33. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/tests/test_http_integration.py +0 -0
  34. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/tests/test_relay.py +0 -0
  35. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/tests/test_repository_metadata.py +0 -0
  36. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/tests/test_schema_validation.py +0 -0
  37. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/tests/test_sdk.py +0 -0
  38. {debugbundle_python-0.1.2 → debugbundle_python-0.1.5}/tests/test_trigger_token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 0.1.2
3
+ Version: 0.1.5
4
4
  Summary: DebugBundle SDK for Python
5
5
  Author: DebugBundle
6
6
  License-Expression: AGPL-3.0-only
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "debugbundle-python"
7
- version = "0.1.2"
7
+ version = "0.1.5"
8
8
  description = "DebugBundle SDK for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -13,6 +13,7 @@ class CapturePolicy:
13
13
  capture_request_events: str
14
14
  capture_breadcrumbs: str
15
15
  capture_probe_events: str
16
+ immediate_client_error_statuses: tuple[int, ...]
16
17
 
17
18
 
18
19
  @dataclass(frozen=True)
@@ -40,6 +41,7 @@ BALANCED_CAPTURE_POLICY = CapturePolicy(
40
41
  capture_request_events="failures_only",
41
42
  capture_breadcrumbs="exception_only",
42
43
  capture_probe_events="buffer_only",
44
+ immediate_client_error_statuses=(),
43
45
  )
44
46
 
45
47
  MINIMAL_CAPTURE_POLICY = CapturePolicy(
@@ -48,6 +50,7 @@ MINIMAL_CAPTURE_POLICY = CapturePolicy(
48
50
  capture_request_events="failures_only",
49
51
  capture_breadcrumbs="local_only",
50
52
  capture_probe_events="buffer_only",
53
+ immediate_client_error_statuses=(),
51
54
  )
52
55
 
53
56
 
@@ -117,6 +120,9 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None:
117
120
  capture_request_events = _as_non_empty_string(payload.get("capture_request_events"))
118
121
  capture_breadcrumbs = _as_non_empty_string(payload.get("capture_breadcrumbs"))
119
122
  capture_probe_events = _as_non_empty_string(payload.get("capture_probe_events"))
123
+ immediate_client_error_statuses = _parse_immediate_client_error_statuses(
124
+ payload.get("immediate_client_error_statuses")
125
+ )
120
126
 
121
127
  if capture_logs not in {"off", "error", "warning", "info"}:
122
128
  return None
@@ -126,6 +132,8 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None:
126
132
  return None
127
133
  if capture_probe_events not in {"buffer_only", "standalone_when_activated"}:
128
134
  return None
135
+ if immediate_client_error_statuses is None:
136
+ return None
129
137
 
130
138
  return CapturePolicy(
131
139
  preset=preset,
@@ -133,9 +141,25 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None:
133
141
  capture_request_events=capture_request_events,
134
142
  capture_breadcrumbs=capture_breadcrumbs,
135
143
  capture_probe_events=capture_probe_events,
144
+ immediate_client_error_statuses=immediate_client_error_statuses,
136
145
  )
137
146
 
138
147
 
148
+ def _parse_immediate_client_error_statuses(value: object) -> tuple[int, ...] | None:
149
+ if value is None:
150
+ return ()
151
+ if not isinstance(value, list) or len(value) > 12:
152
+ return None
153
+
154
+ statuses: list[int] = []
155
+ for item in value:
156
+ if not isinstance(item, int) or isinstance(item, bool) or item < 400 or item > 499:
157
+ return None
158
+ statuses.append(item)
159
+
160
+ return tuple(sorted(set(statuses)))
161
+
162
+
139
163
  def _parse_directive(payload: object) -> RemoteProbeDirective | None:
140
164
  if not isinstance(payload, dict):
141
165
  return None
@@ -53,6 +53,11 @@ LEVEL_RANKS = {
53
53
  "error": 40,
54
54
  "critical": 50,
55
55
  }
56
+ BALANCED_IMMEDIATE_REQUEST_STATUSES = {408, 423, 424, 425, 429}
57
+ INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES = BALANCED_IMMEDIATE_REQUEST_STATUSES | {409}
58
+ BALANCED_STANDARD_ANOMALY_STATUSES = {401, 403, 404, 409, 422}
59
+ BALANCED_HIGH_VOLUME_ANOMALY_STATUSES = {400, 410}
60
+ INVESTIGATIVE_ANOMALY_STATUSES = BALANCED_STANDARD_ANOMALY_STATUSES | BALANCED_HIGH_VOLUME_ANOMALY_STATUSES
56
61
 
57
62
 
58
63
  @dataclass
@@ -604,7 +609,11 @@ class DebugBundleSdk:
604
609
  candidate = response.get("status_code") or response.get("response_status")
605
610
  if isinstance(candidate, int):
606
611
  status_code = candidate
607
- if status_code is not None and status_code >= 500:
612
+ if _is_immediate_request_incident_status(
613
+ status_code,
614
+ self._capture_policy.preset,
615
+ self._capture_policy.immediate_client_error_statuses,
616
+ ):
608
617
  return True
609
618
  if policy == "off":
610
619
  return False
@@ -615,7 +624,7 @@ class DebugBundleSdk:
615
624
  if status_code is None:
616
625
  return policy == "filtered"
617
626
  if policy == "failures_only":
618
- return False
627
+ return _is_request_anomaly_candidate_status(status_code, self._capture_policy.preset)
619
628
  if policy == "filtered":
620
629
  return False
621
630
  return True
@@ -846,6 +855,34 @@ def _iso_now(time_provider: Callable[[], float]) -> str:
846
855
  return datetime.fromtimestamp(time_provider(), tz=timezone.utc).isoformat().replace("+00:00", "Z")
847
856
 
848
857
 
858
+ def _is_immediate_request_incident_status(
859
+ status_code: int | None,
860
+ preset: str,
861
+ immediate_client_error_statuses: tuple[int, ...],
862
+ ) -> bool:
863
+ if status_code is None:
864
+ return False
865
+ if status_code >= 500:
866
+ return True
867
+ if status_code in immediate_client_error_statuses:
868
+ return True
869
+ if preset == "investigative":
870
+ return status_code in INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES
871
+ if preset == "balanced":
872
+ return status_code in BALANCED_IMMEDIATE_REQUEST_STATUSES
873
+ return False
874
+
875
+
876
+ def _is_request_anomaly_candidate_status(status_code: int | None, preset: str) -> bool:
877
+ if status_code is None or status_code < 400 or status_code >= 500:
878
+ return False
879
+ if preset == "investigative":
880
+ return status_code in INVESTIGATIVE_ANOMALY_STATUSES
881
+ if preset == "balanced":
882
+ return status_code in BALANCED_STANDARD_ANOMALY_STATUSES or status_code in BALANCED_HIGH_VOLUME_ANOMALY_STATUSES
883
+ return False
884
+
885
+
849
886
  def _time_now() -> float:
850
887
  return datetime.now(tz=timezone.utc).timestamp()
851
888
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 0.1.2
3
+ Version: 0.1.5
4
4
  Summary: DebugBundle SDK for Python
5
5
  Author: DebugBundle
6
6
  License-Expression: AGPL-3.0-only
@@ -226,4 +226,134 @@ def test_capture_policy_filters_logs_and_request_events_from_remote_config() ->
226
226
  events = _events(transport)
227
227
  assert [event["event_type"] for event in events] == ["log_event", "request_event"]
228
228
  assert events[0]["payload"]["message"] == "error kept"
229
- assert events[1]["payload"]["response_status"] == 503
229
+ assert events[1]["payload"]["response_status"] == 503
230
+
231
+
232
+ def test_balanced_capture_policy_keeps_request_failure_anomaly_candidates() -> None:
233
+ clock = ManualClock()
234
+ fetch = FakeFetch(
235
+ responses=[
236
+ FakeConfigResponse(
237
+ 200,
238
+ {
239
+ "probes_enabled": True,
240
+ "remote_probes_enabled": True,
241
+ "active_probes": [],
242
+ "poll_interval_ms": 15000,
243
+ "capture_policy": {
244
+ "preset": "balanced",
245
+ "capture_logs": "warning",
246
+ "capture_request_events": "failures_only",
247
+ "capture_breadcrumbs": "exception_only",
248
+ "capture_probe_events": "buffer_only",
249
+ },
250
+ },
251
+ )
252
+ ]
253
+ )
254
+ transport = FakeTransport()
255
+ sdk = DebugBundleSdk(transport=transport, time_provider=clock.time)
256
+ sdk.init(
257
+ project_token="dbundle_proj_test",
258
+ service="checkout-api",
259
+ environment="production",
260
+ fetch_impl=fetch,
261
+ )
262
+
263
+ sdk.capture_request({"method": "POST", "path": "/checkout", "headers": {}}, {"status_code": 429})
264
+ sdk.capture_request({"method": "POST", "path": "/checkout", "headers": {}}, {"status_code": 404})
265
+ sdk.capture_request({"method": "POST", "path": "/checkout", "headers": {}}, {"status_code": 409})
266
+ sdk.flush()
267
+
268
+ events = _events(transport)
269
+ request_statuses = [
270
+ event["payload"]["response_status"]
271
+ for event in events
272
+ if event["event_type"] == "request_event"
273
+ ]
274
+ assert request_statuses == [429, 404, 409]
275
+
276
+
277
+ def test_investigative_capture_policy_promotes_409_even_when_request_capture_is_off() -> None:
278
+ clock = ManualClock()
279
+ fetch = FakeFetch(
280
+ responses=[
281
+ FakeConfigResponse(
282
+ 200,
283
+ {
284
+ "probes_enabled": True,
285
+ "remote_probes_enabled": True,
286
+ "active_probes": [],
287
+ "poll_interval_ms": 15000,
288
+ "capture_policy": {
289
+ "preset": "investigative",
290
+ "capture_logs": "info",
291
+ "capture_request_events": "off",
292
+ "capture_breadcrumbs": "standalone",
293
+ "capture_probe_events": "standalone_when_activated",
294
+ },
295
+ },
296
+ )
297
+ ]
298
+ )
299
+ transport = FakeTransport()
300
+ sdk = DebugBundleSdk(transport=transport, time_provider=clock.time)
301
+ sdk.init(
302
+ project_token="dbundle_proj_test",
303
+ service="checkout-api",
304
+ environment="production",
305
+ fetch_impl=fetch,
306
+ )
307
+
308
+ sdk.capture_request({"method": "POST", "path": "/checkout", "headers": {}}, {"status_code": 409})
309
+ sdk.capture_request({"method": "POST", "path": "/checkout", "headers": {}}, {"status_code": 404})
310
+ sdk.flush()
311
+
312
+ events = _events(transport)
313
+ assert [event["payload"]["response_status"] for event in events if event["event_type"] == "request_event"] == [409]
314
+
315
+
316
+ def test_capture_policy_promotes_configured_client_error_statuses_when_request_capture_is_off() -> None:
317
+ clock = ManualClock()
318
+ fetch = FakeFetch(
319
+ responses=[
320
+ FakeConfigResponse(
321
+ 200,
322
+ {
323
+ "probes_enabled": True,
324
+ "remote_probes_enabled": True,
325
+ "active_probes": [],
326
+ "poll_interval_ms": 15000,
327
+ "capture_policy": {
328
+ "preset": "minimal",
329
+ "capture_logs": "error",
330
+ "capture_request_events": "off",
331
+ "capture_breadcrumbs": "local_only",
332
+ "capture_probe_events": "buffer_only",
333
+ "immediate_client_error_statuses": [422, 403, 403],
334
+ },
335
+ },
336
+ )
337
+ ]
338
+ )
339
+ transport = FakeTransport()
340
+ sdk = DebugBundleSdk(transport=transport, time_provider=clock.time)
341
+ sdk.init(
342
+ project_token="dbundle_proj_test",
343
+ service="checkout-api",
344
+ environment="production",
345
+ fetch_impl=fetch,
346
+ )
347
+
348
+ sdk.capture_request({"method": "POST", "path": "/checkout", "headers": {}}, {"status_code": 403})
349
+ sdk.capture_request({"method": "POST", "path": "/checkout", "headers": {}}, {"status_code": 404})
350
+ sdk.capture_request({"method": "POST", "path": "/checkout", "headers": {}}, {"status_code": 422})
351
+ sdk.flush()
352
+
353
+ events = _events(transport)
354
+ request_statuses = [
355
+ event["payload"]["response_status"]
356
+ for event in events
357
+ if event["event_type"] == "request_event"
358
+ ]
359
+ assert request_statuses == [403, 422]