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.
- debugbundle/acknowledgement.py +3 -1
- debugbundle/capture_hooks.py +65 -0
- debugbundle/core.py +347 -262
- debugbundle/delivery_hooks.py +62 -0
- debugbundle/event_support.py +1 -1
- debugbundle/exception_snapshot.py +74 -0
- debugbundle/flush_aggregates.py +37 -0
- debugbundle/probe_capture.py +101 -0
- debugbundle/process_support.py +59 -0
- debugbundle/queue_admission.py +39 -0
- debugbundle/request_capture_policy.py +45 -0
- debugbundle/suppression.py +13 -6
- debugbundle/transport.py +30 -7
- {debugbundle_python-1.5.0.dist-info → debugbundle_python-2.0.1.dist-info}/METADATA +12 -1
- {debugbundle_python-1.5.0.dist-info → debugbundle_python-2.0.1.dist-info}/RECORD +18 -10
- {debugbundle_python-1.5.0.dist-info → debugbundle_python-2.0.1.dist-info}/WHEEL +0 -0
- {debugbundle_python-1.5.0.dist-info → debugbundle_python-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {debugbundle_python-1.5.0.dist-info → debugbundle_python-2.0.1.dist-info}/top_level.txt +0 -0
|
@@ -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]
|
debugbundle/event_support.py
CHANGED
|
@@ -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
|
debugbundle/suppression.py
CHANGED
|
@@ -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:
|
|
32
|
+
self._states: OrderedDict[str, SuppressionState] = OrderedDict()
|
|
31
33
|
|
|
32
34
|
def should_capture(self, key: str, now: float) -> bool:
|
|
33
|
-
|
|
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[
|
|
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[
|
|
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[
|
|
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":
|
|
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
|
-
|
|
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:
|
|
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=
|
|
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=
|
|
6
|
-
debugbundle/
|
|
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=
|
|
14
|
-
debugbundle/transport.py,sha256=
|
|
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-
|
|
25
|
-
debugbundle_python-
|
|
26
|
-
debugbundle_python-
|
|
27
|
-
debugbundle_python-
|
|
28
|
-
debugbundle_python-
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|