debugbundle-python 1.0.0__py3-none-any.whl → 1.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
debugbundle/config.py CHANGED
@@ -6,6 +6,13 @@ from typing import Any
6
6
  DEFAULT_PROBES_POLL_INTERVAL_MS = 60_000
7
7
 
8
8
 
9
+ @dataclass(frozen=True)
10
+ class ImmediateClientErrorPathRule:
11
+ status_code: int
12
+ path_pattern: str
13
+ methods: tuple[str, ...]
14
+
15
+
9
16
  @dataclass(frozen=True)
10
17
  class CapturePolicy:
11
18
  preset: str
@@ -14,6 +21,7 @@ class CapturePolicy:
14
21
  capture_breadcrumbs: str
15
22
  capture_probe_events: str
16
23
  immediate_client_error_statuses: tuple[int, ...]
24
+ immediate_client_error_path_rules: tuple[ImmediateClientErrorPathRule, ...] = ()
17
25
 
18
26
 
19
27
  @dataclass(frozen=True)
@@ -42,6 +50,7 @@ BALANCED_CAPTURE_POLICY = CapturePolicy(
42
50
  capture_breadcrumbs="exception_only",
43
51
  capture_probe_events="buffer_only",
44
52
  immediate_client_error_statuses=(),
53
+ immediate_client_error_path_rules=(),
45
54
  )
46
55
 
47
56
  MINIMAL_CAPTURE_POLICY = CapturePolicy(
@@ -51,6 +60,7 @@ MINIMAL_CAPTURE_POLICY = CapturePolicy(
51
60
  capture_breadcrumbs="local_only",
52
61
  capture_probe_events="buffer_only",
53
62
  immediate_client_error_statuses=(),
63
+ immediate_client_error_path_rules=(),
54
64
  )
55
65
 
56
66
 
@@ -123,6 +133,9 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None:
123
133
  immediate_client_error_statuses = _parse_immediate_client_error_statuses(
124
134
  payload.get("immediate_client_error_statuses")
125
135
  )
136
+ immediate_client_error_path_rules = _parse_immediate_client_error_path_rules(
137
+ payload.get("immediate_client_error_path_rules")
138
+ )
126
139
 
127
140
  if capture_logs not in {"off", "error", "warning", "info"}:
128
141
  return None
@@ -132,7 +145,7 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None:
132
145
  return None
133
146
  if capture_probe_events not in {"buffer_only", "standalone_when_activated"}:
134
147
  return None
135
- if immediate_client_error_statuses is None:
148
+ if immediate_client_error_statuses is None or immediate_client_error_path_rules is None:
136
149
  return None
137
150
 
138
151
  return CapturePolicy(
@@ -142,6 +155,7 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None:
142
155
  capture_breadcrumbs=capture_breadcrumbs,
143
156
  capture_probe_events=capture_probe_events,
144
157
  immediate_client_error_statuses=immediate_client_error_statuses,
158
+ immediate_client_error_path_rules=immediate_client_error_path_rules,
145
159
  )
146
160
 
147
161
 
@@ -160,6 +174,56 @@ def _parse_immediate_client_error_statuses(value: object) -> tuple[int, ...] | N
160
174
  return tuple(sorted(set(statuses)))
161
175
 
162
176
 
177
+ def _parse_immediate_client_error_path_rules(value: object) -> tuple[ImmediateClientErrorPathRule, ...] | None:
178
+ if value is None:
179
+ return ()
180
+ if not isinstance(value, list) or len(value) > 25:
181
+ return None
182
+
183
+ rules: list[ImmediateClientErrorPathRule] = []
184
+ for item in value:
185
+ if not isinstance(item, dict):
186
+ return None
187
+ status_code = item.get("status_code")
188
+ path_pattern = item.get("path_pattern")
189
+ raw_methods = item.get("methods", [])
190
+ if (
191
+ not isinstance(status_code, int)
192
+ or isinstance(status_code, bool)
193
+ or status_code < 400
194
+ or status_code > 499
195
+ or not isinstance(path_pattern, str)
196
+ or not _is_valid_path_pattern(path_pattern)
197
+ or not isinstance(raw_methods, list)
198
+ or len(raw_methods) > 7
199
+ ):
200
+ return None
201
+
202
+ methods: list[str] = []
203
+ for raw_method in raw_methods:
204
+ method = raw_method.upper() if isinstance(raw_method, str) else ""
205
+ if method not in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}:
206
+ return None
207
+ if method not in methods:
208
+ methods.append(method)
209
+ rules.append(
210
+ ImmediateClientErrorPathRule(
211
+ status_code=status_code,
212
+ path_pattern=path_pattern,
213
+ methods=tuple(methods),
214
+ )
215
+ )
216
+
217
+ return tuple(rules)
218
+
219
+
220
+ def _is_valid_path_pattern(value: str) -> bool:
221
+ if not value.startswith("/") or len(value) == 0 or len(value) > 256 or "?" in value or "#" in value:
222
+ return False
223
+ wildcard_index = value.find("*")
224
+ return wildcard_index == -1 or wildcard_index == len(value) - 1
225
+
226
+
163
227
  def _parse_directive(payload: object) -> RemoteProbeDirective | None:
164
228
  if not isinstance(payload, dict):
165
229
  return None
debugbundle/core.py CHANGED
@@ -55,9 +55,6 @@ LEVEL_RANKS = {
55
55
  }
56
56
  BALANCED_IMMEDIATE_REQUEST_STATUSES = {408, 423, 424, 425, 429}
57
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
61
58
 
62
59
 
63
60
  @dataclass
@@ -286,7 +283,12 @@ class DebugBundleSdk:
286
283
  context: Mapping[str, object] | None = None,
287
284
  ) -> None:
288
285
  with self._lock:
289
- if not self._enabled or not self._passes_sample_rate() or not self._should_capture_request_event(response):
286
+ should_capture = (
287
+ self._enabled
288
+ and self._passes_sample_rate()
289
+ and self._should_capture_request_event(request, response)
290
+ )
291
+ if not should_capture:
290
292
  return
291
293
  payload = _request_event_payload(
292
294
  _redact_mapping(dict(request), self._redact_fields),
@@ -526,23 +528,27 @@ class DebugBundleSdk:
526
528
  payload: dict[str, object],
527
529
  context: Mapping[str, object] | None = None,
528
530
  ) -> dict[str, object]:
529
- return {
531
+ merged_context = self._merged_context(context)
532
+ event: dict[str, object] = {
530
533
  "schema_version": SCHEMA_VERSION,
531
534
  "event_id": str(uuid.uuid4()),
532
535
  "event_type": event_type,
533
536
  "occurred_at": _iso_now(self._time_provider),
534
537
  "sdk_name": "debugbundle-python",
535
538
  "sdk_version": _sdk_version(),
536
- "sdk_language": "python",
537
539
  "service": {
538
540
  "name": self._service,
539
541
  "runtime": "python",
540
542
  "framework": None,
541
543
  "environment": self._environment,
542
544
  },
543
- "correlation": _correlation_payload(self._merged_context(context)),
545
+ "correlation": _correlation_payload(merged_context),
544
546
  "payload": payload,
545
547
  }
548
+ envelope_context = _event_context(merged_context)
549
+ if envelope_context:
550
+ event["context"] = envelope_context
551
+ return event
546
552
 
547
553
  def _merged_context(self, context: Mapping[str, object] | None = None) -> dict[str, object]:
548
554
  merged = dict(self._context)
@@ -602,17 +608,31 @@ class DebugBundleSdk:
602
608
  policy_threshold = self._capture_policy.capture_logs
603
609
  return self._log_level if LEVEL_RANKS[self._log_level] >= LEVEL_RANKS[policy_threshold] else policy_threshold
604
610
 
605
- def _should_capture_request_event(self, response: Mapping[str, object] | None) -> bool:
611
+ def _should_capture_request_event(
612
+ self,
613
+ request: Mapping[str, object] | None,
614
+ response: Mapping[str, object] | None,
615
+ ) -> bool:
606
616
  policy = self._capture_policy.capture_request_events
607
617
  status_code = None
608
618
  if response is not None:
609
619
  candidate = response.get("status_code") or response.get("response_status")
610
620
  if isinstance(candidate, int):
611
621
  status_code = candidate
622
+ request_path = None
623
+ http_method = None
624
+ if request is not None:
625
+ path_candidate = request.get("path") or request.get("url")
626
+ method_candidate = request.get("method")
627
+ request_path = path_candidate if isinstance(path_candidate, str) else None
628
+ http_method = method_candidate if isinstance(method_candidate, str) else None
612
629
  if _is_immediate_request_incident_status(
613
630
  status_code,
614
631
  self._capture_policy.preset,
615
632
  self._capture_policy.immediate_client_error_statuses,
633
+ request_path,
634
+ http_method,
635
+ self._capture_policy.immediate_client_error_path_rules,
616
636
  ):
617
637
  return True
618
638
  if policy == "off":
@@ -624,7 +644,7 @@ class DebugBundleSdk:
624
644
  if status_code is None:
625
645
  return policy == "filtered"
626
646
  if policy == "failures_only":
627
- return _is_request_anomaly_candidate_status(status_code, self._capture_policy.preset)
647
+ return status_code >= 500
628
648
  if policy == "filtered":
629
649
  return False
630
650
  return True
@@ -845,6 +865,14 @@ def _correlation_payload(context: Mapping[str, object]) -> dict[str, str | None]
845
865
  }
846
866
 
847
867
 
868
+ def _event_context(context: Mapping[str, object]) -> dict[str, object]:
869
+ return {
870
+ str(key): value
871
+ for key, value in context.items()
872
+ if key not in {"request", "response", "correlation", "request_id", "trace_id", "session_id", "user_id_hash"}
873
+ }
874
+
875
+
848
876
  def _coerce_optional_string(value: object) -> str | None:
849
877
  if value is None:
850
878
  return None
@@ -859,6 +887,9 @@ def _is_immediate_request_incident_status(
859
887
  status_code: int | None,
860
888
  preset: str,
861
889
  immediate_client_error_statuses: tuple[int, ...],
890
+ request_path: str | None = None,
891
+ http_method: str | None = None,
892
+ immediate_client_error_path_rules: tuple[object, ...] = (),
862
893
  ) -> bool:
863
894
  if status_code is None:
864
895
  return False
@@ -866,6 +897,13 @@ def _is_immediate_request_incident_status(
866
897
  return True
867
898
  if status_code in immediate_client_error_statuses:
868
899
  return True
900
+ if _matches_immediate_client_error_path_rule(
901
+ status_code,
902
+ request_path,
903
+ http_method,
904
+ immediate_client_error_path_rules,
905
+ ):
906
+ return True
869
907
  if preset == "investigative":
870
908
  return status_code in INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES
871
909
  if preset == "balanced":
@@ -873,16 +911,41 @@ def _is_immediate_request_incident_status(
873
911
  return False
874
912
 
875
913
 
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:
914
+ def _matches_immediate_client_error_path_rule(
915
+ status_code: int,
916
+ request_path: str | None,
917
+ http_method: str | None,
918
+ rules: tuple[object, ...],
919
+ ) -> bool:
920
+ if status_code < 400 or status_code > 499 or request_path is None:
878
921
  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
922
+ normalized_path = _normalize_request_path(request_path)
923
+ normalized_method = http_method.upper() if isinstance(http_method, str) else None
924
+ for rule in rules:
925
+ rule_status = getattr(rule, "status_code", None)
926
+ path_pattern = getattr(rule, "path_pattern", None)
927
+ methods = getattr(rule, "methods", ())
928
+ if rule_status != status_code or not isinstance(path_pattern, str):
929
+ continue
930
+ if methods and (normalized_method is None or normalized_method not in methods):
931
+ continue
932
+ if path_pattern.endswith("*"):
933
+ if normalized_path.startswith(path_pattern[:-1]):
934
+ return True
935
+ elif normalized_path == path_pattern:
936
+ return True
883
937
  return False
884
938
 
885
939
 
940
+ def _normalize_request_path(value: str) -> str:
941
+ from urllib.parse import urlparse
942
+
943
+ parsed = urlparse(value)
944
+ if parsed.path:
945
+ return parsed.path
946
+ return value.split("?", 1)[0].split("#", 1)[0] if value.startswith("/") else "/"
947
+
948
+
886
949
  def _time_now() -> float:
887
950
  return datetime.now(tz=timezone.utc).timestamp()
888
951
 
@@ -891,7 +954,7 @@ def _sdk_version() -> str:
891
954
  try:
892
955
  return metadata.version("debugbundle-python")
893
956
  except metadata.PackageNotFoundError:
894
- return "1.0.0"
957
+ return "1.1.2"
895
958
 
896
959
 
897
960
  def _sdk_config_endpoint(events_endpoint: str) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 1.0.0
3
+ Version: 1.1.2
4
4
  Summary: DebugBundle SDK for Python
5
5
  Author: DebugBundle
6
6
  License-Expression: AGPL-3.0-only
@@ -1,6 +1,6 @@
1
1
  debugbundle/__init__.py,sha256=mqibduUrdcr_fnb6k3Gwf93H7iqVBS8zd6SceX-U3Q4,5011
2
- debugbundle/config.py,sha256=Cty7Wkj2nIRf5bumsI-dexrvQTFwVICPKrcx6LkhU4Q,6960
3
- debugbundle/core.py,sha256=e3JXW7cVA1OllcTphFLJDJpQvSPOwqigjG6JsVrez88,35772
2
+ debugbundle/config.py,sha256=ENRSR5jUI7xYdBqUakWws3GIjBk00GWFmwXVE_iQp2Y,9296
3
+ debugbundle/core.py,sha256=wRzT4DmGRHkrsakbqHSr35b8O8yI2V9DIjEHMdqfhGo,37776
4
4
  debugbundle/logger_integrations.py,sha256=RuTNaD9RRVmiE-BBkksAXWVEGaMzLrWavVpQdgGZBpE,4564
5
5
  debugbundle/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
6
  debugbundle/redaction.py,sha256=QTaPkSsYv54yR1mz8SB6Q4vWjvp7Ddcui4WXaH2RVz8,736
@@ -17,8 +17,8 @@ debugbundle/integrations/flask.py,sha256=cgyHbsAH96gpNPf_5IGJYzp2va28JdDgXROBsF-
17
17
  debugbundle/integrations/relay_django.py,sha256=Wj8BE9D5otU2KmtjgqdKIJ-BvXVcagCKgpLYvnPZ6WE,2281
18
18
  debugbundle/integrations/relay_fastapi.py,sha256=-Zl6bvhYMLii__mP6-UvSdwxS5VKsb9OZvk-yy-3hEw,2227
19
19
  debugbundle/integrations/relay_flask.py,sha256=VFHkDTJy4LkZzL_Ry_Ba-e_gW6UTG4PBwnLcvB_oXuQ,2011
20
- debugbundle_python-1.0.0.dist-info/licenses/LICENSE,sha256=AKZZ5DQAHrOKGwt24VoRd-SXJLM9OloxlsX8ZgENdEY,735
21
- debugbundle_python-1.0.0.dist-info/METADATA,sha256=woRNAvpIJvyVyCbMQ54ekzmcqKpguNTBQqEZFkr8DaY,13841
22
- debugbundle_python-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
23
- debugbundle_python-1.0.0.dist-info/top_level.txt,sha256=RCB9STTFnl1OKdojxz-xhaks2zkRFs1meZXsKnm18LM,12
24
- debugbundle_python-1.0.0.dist-info/RECORD,,
20
+ debugbundle_python-1.1.2.dist-info/licenses/LICENSE,sha256=AKZZ5DQAHrOKGwt24VoRd-SXJLM9OloxlsX8ZgENdEY,735
21
+ debugbundle_python-1.1.2.dist-info/METADATA,sha256=j72wBHvKBxZZjjTkpcUrjVuLYza_T3Hcbi37hHBRFe8,13841
22
+ debugbundle_python-1.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
23
+ debugbundle_python-1.1.2.dist-info/top_level.txt,sha256=RCB9STTFnl1OKdojxz-xhaks2zkRFs1meZXsKnm18LM,12
24
+ debugbundle_python-1.1.2.dist-info/RECORD,,