debugbundle-python 1.5.0__py3-none-any.whl → 2.0.1__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.
@@ -0,0 +1,62 @@
1
+ """Finalize one reserved event at a time without retaining uncharged hook output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ from .request_capture_policy import should_capture_request_event
8
+
9
+ if TYPE_CHECKING:
10
+ from .core import DebugBundleSdk
11
+
12
+
13
+ def replacement_allowed(sdk: DebugBundleSdk, event: dict[str, object]) -> bool:
14
+ payload = cast(dict[str, object], event["payload"])
15
+ if event["event_type"] == "log_event":
16
+ return sdk._log_level_eligible(cast(str, payload["level"]))
17
+ if event["event_type"] == "request_event":
18
+ return should_capture_request_event(sdk._capture_policy, payload, payload)
19
+ if event["event_type"] == "probe_event":
20
+ return sdk._capture_policy.capture_probe_events == "standalone_when_activated"
21
+ return True
22
+
23
+
24
+ def finalize_batch(
25
+ sdk: DebugBundleSdk,
26
+ batch: list[dict[str, object]],
27
+ generation: int,
28
+ max_bytes: int,
29
+ ) -> list[dict[str, object]] | None:
30
+ retained_index = 0
31
+ for snapshot_index, original in enumerate(batch):
32
+ try:
33
+ replacement = sdk._apply_before_send_event(original)
34
+ replacement_bytes = sdk._event_bytes(replacement) if replacement is not None else 0
35
+ except BaseException:
36
+ # Hook exceptions/invalid returns already use the protected original
37
+ # inside apply_before_send. Other failures must withhold telemetry.
38
+ replacement, replacement_bytes = None, 0
39
+ with sdk._lock:
40
+ if generation != sdk._generation:
41
+ return None
42
+ original_bytes = sdk._buffer_sizes[id(original)]
43
+ allowed = replacement is not None and replacement_allowed(sdk, replacement)
44
+ fits = sdk._buffer_bytes - original_bytes + replacement_bytes <= max_bytes
45
+ if allowed and fits:
46
+ assert replacement is not None
47
+ sdk._buffer[retained_index] = replacement
48
+ sdk._buffer_sizes.pop(id(original))
49
+ sdk._buffer_sizes[id(replacement)] = replacement_bytes
50
+ sdk._buffer_bytes += replacement_bytes - original_bytes
51
+ retained_index += 1
52
+ batch[snapshot_index] = replacement
53
+ else:
54
+ if allowed and not fits:
55
+ sdk._pressure_drops += 1
56
+ sdk._buffer.pop(retained_index)
57
+ sdk._buffer_sizes.pop(id(original))
58
+ sdk._buffer_bytes -= original_bytes
59
+ sdk._inflight_count -= 1
60
+ # Release the snapshot reference before another callback can wait.
61
+ batch[snapshot_index] = {}
62
+ return [event for event in batch if event]
@@ -264,7 +264,7 @@ def sdk_version() -> str:
264
264
  try:
265
265
  return metadata.version("debugbundle-python")
266
266
  except metadata.PackageNotFoundError:
267
- return "1.5.0"
267
+ return "2.0.1"
268
268
 
269
269
 
270
270
  def sdk_config_endpoint(events_endpoint: str) -> str:
@@ -0,0 +1,74 @@
1
+ """Bounded exception details without invoking application-defined string renderers or source reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ MAX_CAUSES = 8
6
+ MAX_FRAMES = 64
7
+ MAX_STACK_CHARS = 16 * 1024
8
+
9
+
10
+ def exception_details(error: BaseException) -> tuple[str, str, str]:
11
+ name = _exception_name(error)
12
+ message = _safe_message(error)
13
+ lines: list[str] = []
14
+ visited: set[int] = set()
15
+ current: BaseException | None = error
16
+ frames_seen = 0
17
+
18
+ while current is not None and len(visited) < MAX_CAUSES and id(current) not in visited:
19
+ visited.add(id(current))
20
+ current_name = _exception_name(current)
21
+ lines.append(f"{current_name}: {_safe_message(current)}")
22
+ try:
23
+ tb = BaseException.__dict__["__traceback__"].__get__(current)
24
+ except BaseException:
25
+ tb = None
26
+ while tb is not None and frames_seen < MAX_FRAMES:
27
+ try:
28
+ code = tb.tb_frame.f_code
29
+ filename = code.co_filename[:256]
30
+ function = code.co_name[:128]
31
+ line = tb.tb_lineno
32
+ tb = tb.tb_next
33
+ except BaseException:
34
+ break
35
+ lines.append(f" File {filename}, line {line}, in {function}")
36
+ frames_seen += 1
37
+ if frames_seen >= MAX_FRAMES:
38
+ lines.append(" ... additional frames omitted")
39
+ break
40
+ try:
41
+ next_error = BaseException.__dict__["__cause__"].__get__(current)
42
+ if next_error is None and not BaseException.__dict__["__suppress_context__"].__get__(current):
43
+ next_error = BaseException.__dict__["__context__"].__get__(current)
44
+ except BaseException:
45
+ next_error = None
46
+ current = next_error
47
+ if current is not None:
48
+ lines.append("Caused by:")
49
+
50
+ return name, message, "\n".join(lines)[:MAX_STACK_CHARS]
51
+
52
+
53
+ def _exception_name(error: BaseException) -> str:
54
+ # Invoke built-in descriptors directly: subclass properties, __getattribute__,
55
+ # metaclass accessors, and truthiness must never execute on capture callers.
56
+ return str(type.__dict__["__name__"].__get__(type(error)))[:256]
57
+
58
+
59
+ def _safe_message(error: BaseException) -> str:
60
+ try:
61
+ args = BaseException.__dict__["args"].__get__(error)
62
+ if not isinstance(args, tuple):
63
+ return "[message unavailable]"
64
+ parts: list[str] = []
65
+ for value in args[:4]:
66
+ if issubclass(type(value), str):
67
+ parts.append(str.__getitem__(value, slice(0, 1024)))
68
+ elif type(value) in (int, float, bool):
69
+ parts.append(str(value))
70
+ else:
71
+ parts.append("[unsupported argument]")
72
+ return ", ".join(parts)[:4096]
73
+ except BaseException:
74
+ return "[message unavailable]"
@@ -0,0 +1,37 @@
1
+ """Prepare bounded SDK summaries away from the capture admission lock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, cast
6
+
7
+ if TYPE_CHECKING:
8
+ from .core import DebugBundleSdk
9
+
10
+
11
+ def prepare_flush_aggregates(
12
+ sdk: DebugBundleSdk,
13
+ suppression: list[dict[str, object]],
14
+ pressure_count: int,
15
+ ) -> tuple[list[tuple[dict[str, object], int]], tuple[dict[str, object], int] | None]:
16
+ prepared: list[tuple[dict[str, object], int]] = []
17
+ for aggregate in suppression:
18
+ event_type = aggregate.get("event_type")
19
+ payload = aggregate.get("payload")
20
+ if not isinstance(event_type, str) or not isinstance(payload, dict):
21
+ continue
22
+ aggregate.update(sdk._base_event(event_type, cast(dict[str, object], payload)))
23
+ protected = sdk._protect_event(aggregate)
24
+ if protected is not None:
25
+ prepared.append((protected, sdk._event_bytes(protected)))
26
+
27
+ pressure: tuple[dict[str, object], int] | None = None
28
+ if pressure_count:
29
+ event = sdk._base_event("log_event", {
30
+ "message": f"{pressure_count} SDK events suppressed due to queue pressure",
31
+ "level": "warning",
32
+ "attributes": {"suppressed_count": pressure_count, "reason": "queue_pressure"},
33
+ })
34
+ protected = sdk._protect_event(event)
35
+ if protected is not None:
36
+ pressure = (protected, sdk._event_bytes(protected))
37
+ return prepared, pressure
@@ -0,0 +1,101 @@
1
+ """Probe capture with short admission/commit locks and no application callback under them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass
8
+ from typing import TYPE_CHECKING, cast
9
+
10
+ from .capture_hooks import try_capture_lock
11
+ from .event_support import iso_now
12
+ from .process_support import ensure_current_process
13
+ from .redaction import sanitize_telemetry
14
+
15
+ if TYPE_CHECKING:
16
+ from .core import DebugBundleSdk
17
+
18
+
19
+ @dataclass
20
+ class ProbeEntry:
21
+ label: str
22
+ data: dict[str, object]
23
+ timestamp: str
24
+
25
+
26
+ def capture_probe(
27
+ sdk: DebugBundleSdk,
28
+ label: str,
29
+ data: object | Callable[[], object],
30
+ opts: Mapping[str, object] | None,
31
+ ) -> None:
32
+ try:
33
+ ensure_current_process(sdk)
34
+ options = dict(opts or {})
35
+ now_ms = int(sdk._time_provider() * 1000)
36
+ if not try_capture_lock(sdk):
37
+ sdk._pressure_drops += 1
38
+ return
39
+ try:
40
+ if not sdk._enabled:
41
+ return
42
+ heavy = options.get("heavy") is True
43
+ directives = sdk._find_matching_probe_directives(label, now_ms)
44
+ if heavy and not directives:
45
+ return
46
+ if label not in sdk._probe_buffers and len(sdk._probe_buffers) >= sdk._max_probe_labels:
47
+ return
48
+ redact_fields = set(sdk._redact_fields)
49
+ generation = sdk._generation
50
+ finally:
51
+ sdk._lock.release()
52
+
53
+ # A host supplier, mapping iterator, privacy walk, clock, or serializer may
54
+ # be slow or hostile. None may hold the capture lock or block exceptions.
55
+ protected_label = sanitize_telemetry(label, redact_fields)
56
+ value = data() if callable(data) else data
57
+ if not isinstance(value, Mapping):
58
+ value = {"value": value}
59
+ protected_value = sanitize_telemetry(dict(value), redact_fields)
60
+ if not isinstance(protected_label, str) or not isinstance(protected_value, dict):
61
+ return
62
+ label = protected_label
63
+ redacted_value = cast(dict[str, object], protected_value)
64
+ entry = None if heavy else ProbeEntry(label, redacted_value, iso_now(sdk._time_provider))
65
+
66
+ prepared: list[tuple[str, dict[str, object], int]] = []
67
+ for directive in directives:
68
+ payload: dict[str, object] = {
69
+ "label": label,
70
+ "activation_id": directive.id,
71
+ "probe_label_pattern": directive.label_pattern,
72
+ "data": dict(redacted_value),
73
+ }
74
+ event = sdk._protect_event(sdk._base_event("probe_event", payload))
75
+ if event is not None:
76
+ prepared.append((directive.id, event, sdk._event_bytes(event)))
77
+
78
+ refreshed_now_ms = int(sdk._time_provider() * 1000)
79
+ if not try_capture_lock(sdk):
80
+ sdk._pressure_drops += 1
81
+ return
82
+ try:
83
+ if not sdk._enabled or generation != sdk._generation:
84
+ return
85
+ if label not in sdk._probe_buffers and len(sdk._probe_buffers) >= sdk._max_probe_labels:
86
+ return
87
+ active_ids = {directive.id for directive in sdk._find_matching_probe_directives(label, refreshed_now_ms)}
88
+ if heavy and not active_ids:
89
+ return
90
+ if entry is not None:
91
+ bucket = sdk._probe_buffers.setdefault(label, deque(maxlen=sdk._max_probe_entries_per_label))
92
+ bucket.append(entry)
93
+ if sdk._capture_policy.capture_probe_events == "standalone_when_activated":
94
+ for directive_id, event, event_bytes in prepared:
95
+ if directive_id in active_ids:
96
+ sdk._offer_protected_event(event, event_bytes)
97
+ finally:
98
+ sdk._lock.release()
99
+ except BaseException:
100
+ # Probe callbacks and SDK failures must remain observational.
101
+ return
@@ -0,0 +1,59 @@
1
+ """Discard pre-fork SDK ownership without touching inherited thread locks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ from contextvars import ContextVar
8
+ from typing import TYPE_CHECKING
9
+
10
+ from .suppression import EventSuppressionTracker
11
+ from .transport import HttpTransport
12
+
13
+ if TYPE_CHECKING:
14
+ from .core import DebugBundleSdk
15
+
16
+
17
+ def ensure_current_process(sdk: DebugBundleSdk) -> None:
18
+ pid = os.getpid()
19
+ if sdk._pid == pid:
20
+ return
21
+
22
+ # The parent may have forked while another thread owned its lock. Never
23
+ # acquire, cancel, close, or join any inherited thread-owned object here.
24
+ sdk._lock = threading.RLock()
25
+ sdk._timer = None
26
+ sdk._remote_config_timer = None
27
+ sdk._initial_config_ready = threading.Event()
28
+ sdk._initial_config_ready.set()
29
+ sdk._generation += 1
30
+ sdk._sending = False
31
+ sdk._inflight_count = 0
32
+ sdk._timer_due_at = 0.0
33
+ sdk._buffer = []
34
+ sdk._buffer_sizes = {}
35
+ sdk._buffer_bytes = 0
36
+ sdk._pending_low_priority = 0
37
+ sdk._context = {}
38
+ sdk._scoped_context = ContextVar("debugbundle_scoped_context", default=None)
39
+ sdk._request_trigger_directives = ContextVar("debugbundle_request_trigger_directives", default=None)
40
+ sdk._probe_buffers = {}
41
+ sdk._suppression = EventSuppressionTracker()
42
+ sdk._pressure_drops = 0
43
+ sdk._last_pressure_report_at = 0.0
44
+ sdk._retry_after = 0.0
45
+ sdk._last_event_at = None
46
+ sdk._consecutive_failures = 0
47
+ sdk._remote_config_etag = None
48
+ sdk._remote_config_snapshot = None
49
+
50
+ # Retain the parent's effective policy until the child explicitly reinitializes;
51
+ # resetting to a default here could widen capture after a restricted policy.
52
+ if sdk._http_transport is not None:
53
+ sdk._http_transport = None
54
+ try:
55
+ sdk._http_transport = HttpTransport(sdk._endpoint)
56
+ except Exception:
57
+ pass
58
+ sdk._transport = sdk._http_transport
59
+ sdk._pid = pid
@@ -0,0 +1,39 @@
1
+ """Constant-time overload admission and pending-event priority helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .core import DebugBundleSdk
9
+
10
+
11
+ def high_priority_event(event: dict[str, object]) -> bool:
12
+ if event.get("event_type") == "backend_exception":
13
+ return True
14
+ payload = event.get("payload")
15
+ return isinstance(payload, dict) and (
16
+ payload.get("level") in ("error", "critical")
17
+ or (event.get("event_type") == "request_event" and type(payload.get("response_status")) is int
18
+ and payload["response_status"] >= 500)
19
+ )
20
+
21
+
22
+ def may_prepare_event(sdk: DebugBundleSdk, high_priority: bool, max_events: int, max_bytes: int) -> bool:
23
+ if len(sdk._buffer) < max_events and sdk._buffer_bytes < max_bytes:
24
+ return True
25
+ if high_priority and sdk._pending_low_priority > 0:
26
+ return True
27
+ sdk._pressure_drops = min(2**63 - 1, sdk._pressure_drops + 1)
28
+ return False
29
+
30
+
31
+ def evict_low_priority(sdk: DebugBundleSdk) -> bool:
32
+ for index in range(sdk._inflight_count, len(sdk._buffer)):
33
+ if not high_priority_event(sdk._buffer[index]):
34
+ evicted = sdk._buffer.pop(index)
35
+ sdk._buffer_bytes -= sdk._buffer_sizes.pop(id(evicted))
36
+ sdk._pending_low_priority -= 1
37
+ sdk._pressure_drops = min(2**63 - 1, sdk._pressure_drops + 1)
38
+ return True
39
+ return False
@@ -0,0 +1,45 @@
1
+ """Metadata-only request admission before request payload construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from .config import CapturePolicy
8
+ from .event_support import is_immediate_request_incident_status
9
+
10
+
11
+ def should_capture_request_event(
12
+ policy: CapturePolicy,
13
+ request: Mapping[str, object] | None,
14
+ response: Mapping[str, object] | None,
15
+ ) -> bool:
16
+ status_code = None
17
+ if response is not None:
18
+ candidate = response.get("status_code") or response.get("response_status")
19
+ if isinstance(candidate, int):
20
+ status_code = candidate
21
+ request_path = None
22
+ http_method = None
23
+ if request is not None:
24
+ path_candidate = request.get("path") or request.get("url")
25
+ method_candidate = request.get("method")
26
+ request_path = path_candidate if isinstance(path_candidate, str) else None
27
+ http_method = method_candidate if isinstance(method_candidate, str) else None
28
+ if is_immediate_request_incident_status(
29
+ status_code,
30
+ policy.preset,
31
+ policy.immediate_client_error_statuses,
32
+ request_path,
33
+ http_method,
34
+ policy.immediate_client_error_path_rules,
35
+ ):
36
+ return True
37
+ if policy.capture_request_events == "off":
38
+ return False
39
+ if policy.capture_request_events == "all":
40
+ return True
41
+ if response is None or status_code is None:
42
+ return policy.capture_request_events == "filtered"
43
+ if policy.capture_request_events == "failures_only":
44
+ return status_code >= 500
45
+ return False
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections import OrderedDict
3
4
  from dataclasses import dataclass
4
5
  from hashlib import sha256
5
6
 
@@ -9,6 +10,7 @@ LOOP_THRESHOLD = 10
9
10
  LOOP_RESET_AFTER_SECONDS = 60.0
10
11
  LOOP_CHECKPOINT_SECONDS = 30.0
11
12
  MAX_NORMAL_EVENTS_PER_WINDOW = 3
13
+ MAX_TRACKED_FINGERPRINTS = 2_048
12
14
 
13
15
 
14
16
  @dataclass
@@ -27,25 +29,30 @@ class SuppressionState:
27
29
 
28
30
  class EventSuppressionTracker:
29
31
  def __init__(self) -> None:
30
- self._states: dict[str, SuppressionState] = {}
32
+ self._states: OrderedDict[str, SuppressionState] = OrderedDict()
31
33
 
32
34
  def should_capture(self, key: str, now: float) -> bool:
33
- state = self._states.get(key)
35
+ fingerprint = sha256(key.encode("utf-8")).hexdigest()
36
+ state = self._states.get(fingerprint)
34
37
  if state is None:
38
+ if len(self._states) >= MAX_TRACKED_FINGERPRINTS:
39
+ self._states.popitem(last=False)
35
40
  state = SuppressionState(
36
41
  window_started_at=now,
37
42
  loop_window_started_at=now,
38
43
  last_seen_at=now,
39
44
  )
40
- self._states[key] = state
45
+ self._states[fingerprint] = state
46
+ else:
47
+ self._states.move_to_end(fingerprint)
41
48
 
42
49
  if state.suppression_mode and now - state.last_seen_at >= LOOP_RESET_AFTER_SECONDS:
43
- self._states[key] = SuppressionState(
50
+ self._states[fingerprint] = SuppressionState(
44
51
  window_started_at=now,
45
52
  loop_window_started_at=now,
46
53
  last_seen_at=now,
47
54
  )
48
- state = self._states[key]
55
+ state = self._states[fingerprint]
49
56
 
50
57
  if now - state.window_started_at >= DUPLICATE_WINDOW_SECONDS:
51
58
  state.window_started_at = now
@@ -94,7 +101,7 @@ class EventSuppressionTracker:
94
101
  {
95
102
  "event_type": "error_suppressed",
96
103
  "payload": {
97
- "fingerprint": sha256(key.encode("utf-8")).hexdigest(),
104
+ "fingerprint": key,
98
105
  "suppressed_count": state.pending_suppressed_count,
99
106
  "first_seen": _to_iso(state.pending_first_seen_at),
100
107
  "last_seen": _to_iso(state.pending_last_seen_at),
debugbundle/transport.py CHANGED
@@ -1,11 +1,40 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import math
4
+ import time
3
5
  from collections.abc import Mapping
4
6
  from dataclasses import dataclass
7
+ from email.utils import parsedate_to_datetime
5
8
  from typing import Any, Protocol
6
9
 
7
10
  import httpx
8
11
 
12
+ MAX_RETRY_AFTER_MS = 300_000
13
+
14
+
15
+ def bounded_retry_after_ms(value: object, default: int = 1_000) -> int:
16
+ # Clamp before conversion so arbitrarily large integer hints cannot overflow a float deadline.
17
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
18
+ return default
19
+ if isinstance(value, float) and not math.isfinite(value):
20
+ return default
21
+ return int(min(MAX_RETRY_AFTER_MS, max(0, value)))
22
+
23
+
24
+ def _parse_retry_after(value: str | None) -> int | None:
25
+ if value is None:
26
+ return None
27
+ try:
28
+ seconds = float(value)
29
+ except ValueError:
30
+ try:
31
+ seconds = parsedate_to_datetime(value).timestamp() - time.time()
32
+ except (ValueError, TypeError, OverflowError):
33
+ return None
34
+ if not math.isfinite(seconds):
35
+ return None
36
+ return bounded_retry_after_ms(min(300, max(0, seconds)) * 1_000)
37
+
9
38
 
10
39
  @dataclass
11
40
  class TransportResponse:
@@ -34,13 +63,7 @@ class HttpTransport:
34
63
  json={"events": request["events"]},
35
64
  headers=headers,
36
65
  )
37
- retry_after_header = response.headers.get("retry-after")
38
- retry_after_ms = None
39
- if retry_after_header is not None:
40
- try:
41
- retry_after_ms = int(float(retry_after_header) * 1000)
42
- except ValueError:
43
- retry_after_ms = None
66
+ retry_after_ms = _parse_retry_after(response.headers.get("retry-after"))
44
67
 
45
68
  try:
46
69
  body: object | None = response.json()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 1.5.0
3
+ Version: 2.0.1
4
4
  Summary: DebugBundle SDK for Python
5
5
  Author: DebugBundle
6
6
  License-Expression: Apache-2.0
@@ -51,6 +51,8 @@ Use this package to capture Python backend exceptions, request metadata, structu
51
51
 
52
52
  Requires Python 3.10 or newer.
53
53
 
54
+ Version 2.0.0 protects capture callers with bounded, priority-aware background delivery. Review the [2.0 migration guide](MIGRATION-2.0.md) before upgrading from 1.x.
55
+
54
56
  ## Automatic capture and application filtering
55
57
 
56
58
  `capture_logging()` attaches the stdlib logging handler and detects installed structlog/loguru integrations. Obtain structlog loggers after attachment. Structlog level filters and `DropEvent` processors run before capture; stdlib-backed structlog loggers also honor logger filters, disabled state and `logging.disable()`. Bound context and async logging are supported. SDK disposal stops capture through cached proxies. DebugBundle has its own minimum capture level; destination-specific filters on other handlers or Loguru sinks do not configure the DebugBundle handler.
@@ -99,6 +101,7 @@ debugbundle.capture_log("payment retry failed", level="warning", context={"order
99
101
  debugbundle.capture_message("worker started")
100
102
  debugbundle.probe("checkout.cart", {"item_count": len(cart.items)})
101
103
 
104
+ # Use only during controlled teardown or tests, never on a request path.
102
105
  debugbundle.flush()
103
106
  ```
104
107
 
@@ -283,8 +286,10 @@ Post-V1 planned expansions from `spec/sdk-language-targets.md` remain out of sco
283
286
  ## Safety Defaults
284
287
 
285
288
  - SDK failures are caught internally and do not crash the host process.
289
+ - A forked worker starts with a fresh SDK queue, lock, context and built-in HTTP client. Initialize the SDK in each child when remote configuration must poll there; custom transports must be fork-safe.
286
290
  - Sensitive fields are redacted before transport.
287
291
  - Duplicate event storms are suppressed locally.
292
+ - Full-queue capture sheds warning/request noise before context scans. Exceptions, ERROR logs, and 5xx request incidents have priority over pending lower-priority events; all-error overloads are bounded and summarized.
288
293
  - Runtime context excludes environment variables.
289
294
  - Browser relay requests cannot smuggle server-side credentials.
290
295
 
@@ -352,3 +357,9 @@ CI validates Ruff, mypy, pytest, package build, event schema fixtures, and cover
352
357
  ## License
353
358
 
354
359
  Apache-2.0. See `LICENSE`.
360
+
361
+ ## Delivery acknowledgement and retry limits
362
+
363
+ The built-in HTTP transport requires the canonical ingestion acknowledgement (`accepted`, `rejected`, and `errors`). Empty responses, unrelated JSON, malformed counts, or invalid rejection indices retain the full batch with backoff. Valid acknowledgements remove accepted and terminally rejected events and retry only the indexed retryable rejections; an all-rejected batch does not advance `lastEventAt`.
364
+
365
+ File transports and explicitly supplied custom/legacy transports retain their documented bodyless success fallback. A custom transport that returns acknowledgement fields must return the complete canonical shape. Retry hints (delay seconds or HTTP dates) are bounded to five minutes and measured from response receipt, including partial/protocol acknowledgements and retryable server failures. Without a server hint, existing retry timing is preserved. Failures remain contained within the SDK's existing delivery path.
@@ -1,17 +1,25 @@
1
1
  debugbundle/__init__.py,sha256=QH5RY2Cdp3Ry5PNSAhiV9akOMoS2pERRqowFnZ94roc,5131
2
- debugbundle/acknowledgement.py,sha256=tyI8XLRG9N3-Weo6uiGrAlf_O4tjptVe1x1cG7RjrMc,2314
2
+ debugbundle/acknowledgement.py,sha256=2miOWbuIfElrbkktJHEuL7a2-IqKYUZCpO1UjSaouYw,2461
3
3
  debugbundle/before_send.py,sha256=_tmV6ovHUBIxfQPMQ8a0C6r91IAnd4DVs8UcjsKhqX0,7792
4
+ debugbundle/capture_hooks.py,sha256=70Q6ske8JulyuYpeE9FohvMreytf4DUrj4QW9f2-o2Q,1940
4
5
  debugbundle/config.py,sha256=ENRSR5jUI7xYdBqUakWws3GIjBk00GWFmwXVE_iQp2Y,9296
5
- debugbundle/core.py,sha256=XnaHLLfo9cNHNy8n-Q2rG5SRQx9pXE3t5-hwpw2F93Y,34639
6
- debugbundle/event_support.py,sha256=TSaqYXla6NACknU1nIofe4gVPQAkUkB-yrav09FTvpA,9046
6
+ debugbundle/core.py,sha256=uu2_VPvXwmccwwCcK1Y1kJgfehMOCrhFjV4L14X0cok,39467
7
+ debugbundle/delivery_hooks.py,sha256=BUqtHuEv1MDOSLv0sUP6D582c06JofsbXhi7I7Y5IGc,2691
8
+ debugbundle/event_support.py,sha256=keOpma92Hxer2mOD9ELw-w5MRFYNOsj5A9C9goLdkDE,9046
9
+ debugbundle/exception_snapshot.py,sha256=_2p55F572sL7_ogBL6Raa137HRyUqvSaHVMwpKiC8fs,2831
10
+ debugbundle/flush_aggregates.py,sha256=ukHsIUykeGp4YJsesA_9NCUMarg_BAXVuzO4bqJKH3Y,1464
7
11
  debugbundle/logger_integrations.py,sha256=T6LR10_DgDS8C-CUPxtC1kZz3qfX5j0B2YUV7LxDovk,3572
12
+ debugbundle/probe_capture.py,sha256=c1a-ZKHyNeu6CMS5zgi8_wIfpLs5-pe12GEXNMpMTVM,3969
13
+ debugbundle/process_support.py,sha256=D1aFhaP1wOGfMdzkdtEhO54R3XWa8dLCZlfWHWN3qA4,1962
8
14
  debugbundle/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ debugbundle/queue_admission.py,sha256=QtFengGZ4KEVbu1UqSp3u8eLucQeS9dGIwrzq62A5cY,1433
9
16
  debugbundle/redaction.py,sha256=mxJgtILpVDUaj8LAhzswfaX1M4Ppyx87lfkhFqbMR8M,9663
10
17
  debugbundle/relay.py,sha256=9HkEEVZpqrebzH6A-sSuFVuFjyLg07JuTMpMG8pu7SY,14726
11
18
  debugbundle/relay_delivery.py,sha256=VL-nIgJR6mYXqKy-EbA0USWeY_iS_uAr1KBzrnwBnnk,4676
19
+ debugbundle/request_capture_policy.py,sha256=fl1EilWAnBQCdGTafRpY0tMlYUoSJK_7hZyJmPHLkeI,1583
12
20
  debugbundle/structlog_integration.py,sha256=hDwCGXTQ8GAjnx2jwlRbpnvLE0y798kFFMsSB3iqOrM,6061
13
- debugbundle/suppression.py,sha256=XMn0GfF_-WZk2wHWk3KfbO5_u5QhEunues5h3jOInLs,4098
14
- debugbundle/transport.py,sha256=V289FwFWL6KV5gyzOsesluEMbpRobm_FHu3x8BpOYKU,2014
21
+ debugbundle/suppression.py,sha256=V_JvDvcV7tN3EHkfpEJGK1WB2G4nR3GhXvaKd400iFQ,4418
22
+ debugbundle/transport.py,sha256=_6L6XdjSSm-YzWsQLH8E5xku6yMS8wBYFpOwuYtY2cg,2764
15
23
  debugbundle/trigger_token.py,sha256=YUwIWnnxu1klAVaB8Ltgk_PwWW_PPjq9rzmEbWXeFeM,4455
16
24
  debugbundle/integrations/__init__.py,sha256=Jr87ImWXzUXOuFP4WlGleK14VARajSSdGZN_nHt5emw,1161
17
25
  debugbundle/integrations/common.py,sha256=iiwf5wDlpujFuWfbO-ziFTn4MtiHBXSIZNXtamhCavg,2014
@@ -21,8 +29,8 @@ debugbundle/integrations/flask.py,sha256=cgyHbsAH96gpNPf_5IGJYzp2va28JdDgXROBsF-
21
29
  debugbundle/integrations/relay_django.py,sha256=Wj8BE9D5otU2KmtjgqdKIJ-BvXVcagCKgpLYvnPZ6WE,2281
22
30
  debugbundle/integrations/relay_fastapi.py,sha256=-Zl6bvhYMLii__mP6-UvSdwxS5VKsb9OZvk-yy-3hEw,2227
23
31
  debugbundle/integrations/relay_flask.py,sha256=VFHkDTJy4LkZzL_Ry_Ba-e_gW6UTG4PBwnLcvB_oXuQ,2011
24
- debugbundle_python-1.5.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
25
- debugbundle_python-1.5.0.dist-info/METADATA,sha256=X1Q7PLLF5B1H2GYePJjV3wCuvkU2_s_q0ynr46dJO74,14569
26
- debugbundle_python-1.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
27
- debugbundle_python-1.5.0.dist-info/top_level.txt,sha256=RCB9STTFnl1OKdojxz-xhaks2zkRFs1meZXsKnm18LM,12
28
- debugbundle_python-1.5.0.dist-info/RECORD,,
32
+ debugbundle_python-2.0.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
33
+ debugbundle_python-2.0.1.dist-info/METADATA,sha256=5el7IjAErunxEkDXAZNfD8eIirCrVCgB5I4kAYBxC88,16205
34
+ debugbundle_python-2.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
35
+ debugbundle_python-2.0.1.dist-info/top_level.txt,sha256=RCB9STTFnl1OKdojxz-xhaks2zkRFs1meZXsKnm18LM,12
36
+ debugbundle_python-2.0.1.dist-info/RECORD,,