debugbundle-python 2.0.1__py3-none-any.whl → 2.1.0__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.
Files changed (35) hide show
  1. debugbundle/__init__.py +16 -0
  2. debugbundle/before_send.py +35 -191
  3. debugbundle/canonical_event.py +202 -0
  4. debugbundle/config.py +6 -0
  5. debugbundle/core.py +76 -318
  6. debugbundle/delivery_hooks.py +14 -0
  7. debugbundle/diagnostic_budget.py +191 -0
  8. debugbundle/event-envelope.schema.json +4904 -0
  9. debugbundle/evidence_delivery.py +115 -0
  10. debugbundle/evidence_http.py +45 -0
  11. debugbundle/evidence_json.py +68 -0
  12. debugbundle/evidence_wire.py +409 -0
  13. debugbundle/exception_capture.py +92 -0
  14. debugbundle/exception_evidence.py +199 -0
  15. debugbundle/exception_snapshot.py +1 -1
  16. debugbundle/flush_delivery.py +203 -0
  17. debugbundle/ingestion_wire.py +75 -0
  18. debugbundle/integrations/relay_django.py +4 -1
  19. debugbundle/integrations/relay_fastapi.py +10 -2
  20. debugbundle/integrations/relay_flask.py +4 -1
  21. debugbundle/operation_context.py +121 -0
  22. debugbundle/process_support.py +7 -0
  23. debugbundle/queue_admission.py +42 -2
  24. debugbundle/recovery_capture.py +43 -0
  25. debugbundle/relay.py +93 -36
  26. debugbundle/relay_delivery.py +108 -17
  27. debugbundle/remote_config_client.py +81 -0
  28. debugbundle/scope_ownership.py +99 -0
  29. debugbundle/transport.py +20 -14
  30. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/METADATA +116 -1
  31. debugbundle_python-2.1.0.dist-info/RECORD +51 -0
  32. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/WHEEL +1 -1
  33. debugbundle_python-2.0.1.dist-info/RECORD +0 -36
  34. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
  35. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,191 @@
1
+ """Bound tracker metadata and pending payload ownership to 64 KiB per destination."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from dataclasses import dataclass, field, replace
8
+ from typing import Any
9
+
10
+
11
+ @dataclass
12
+ class Window:
13
+ starts: float
14
+ failure_at: float | None = None
15
+ failure_id: str | None = None
16
+ attempts: set[str] = field(default_factory=set)
17
+ observations: int = 0
18
+ resource: bool = False
19
+ ambiguous: bool = False
20
+ pending: int = 0
21
+ cost: int = 0
22
+
23
+
24
+ @dataclass
25
+ class Pending:
26
+ scope: str
27
+ kind: str
28
+ attempt: str | None
29
+ size: int
30
+ cost: int
31
+
32
+
33
+ def _key(payload: dict[str, Any]) -> str:
34
+ return json.dumps([payload["failure_ref"], payload["scope"]], sort_keys=True, separators=(",", ":"))
35
+
36
+
37
+ def _cost(key: str, value: Window) -> int:
38
+ return len(
39
+ json.dumps(
40
+ [
41
+ key,
42
+ value.starts,
43
+ value.failure_at,
44
+ value.failure_id,
45
+ sorted(value.attempts),
46
+ value.observations,
47
+ value.resource,
48
+ value.ambiguous,
49
+ value.pending,
50
+ ],
51
+ separators=(",", ":"),
52
+ ).encode("utf-8")
53
+ )
54
+
55
+
56
+ def _size(value: int) -> bool:
57
+ return type(value) is int and 0 <= value <= 65536
58
+
59
+
60
+ class DiagnosticCaptureBudget:
61
+ def __init__(self) -> None:
62
+ self._windows: dict[str, Window] = {}
63
+ self._pending: dict[str, Pending] = {}
64
+ self._held = 0
65
+
66
+ @property
67
+ def accounted_bytes(self) -> int:
68
+ return self._held + sum(window.cost for window in self._windows.values())
69
+
70
+ def clear(self) -> None:
71
+ self._windows.clear()
72
+ self._pending.clear()
73
+ self._held = 0
74
+
75
+ def _prune(self, now: float) -> None:
76
+ self._windows = {
77
+ key: value for key, value in self._windows.items() if value.pending or now < value.starts + 300_000
78
+ }
79
+
80
+ def register_failure(self, payload: dict[str, Any], event_id: str, now: float) -> bool:
81
+ if not math.isfinite(now):
82
+ return False
83
+ self._prune(now)
84
+ key = _key(payload)
85
+ previous = self._windows.get(key)
86
+ if previous is not None and previous.failure_id is not None and previous.failure_id != event_id:
87
+ previous.ambiguous = True
88
+ previous.cost = _cost(key, previous)
89
+ return False
90
+ if previous is None and len(self._windows) >= 50:
91
+ return False
92
+ value = replace(previous) if previous is not None else Window(now)
93
+ value.failure_id = event_id
94
+ value.failure_at = value.failure_at if value.failure_at is not None else now
95
+ value.cost = _cost(key, value)
96
+ if self.accounted_bytes - (previous.cost if previous is not None else 0) + value.cost > 65536:
97
+ return False
98
+ self._windows[key] = value
99
+ return not value.ambiguous
100
+
101
+ def reserve(self, event_id: str, payload: dict[str, Any], size: int, now: float) -> bool:
102
+ if not _size(size) or not math.isfinite(now) or event_id in self._pending:
103
+ return False
104
+ self._prune(now)
105
+ key = _key(payload)
106
+ previous = self._windows.get(key)
107
+ if (previous is None and len(self._windows) >= 50) or (previous is not None and previous.ambiguous):
108
+ return False
109
+ value = replace(previous, attempts=set(previous.attempts)) if previous is not None else Window(now)
110
+ if (
111
+ now < value.starts
112
+ or now > (value.failure_at if value.failure_at is not None else value.starts) + 300_000
113
+ or value.observations >= 20
114
+ ):
115
+ return False
116
+ attempt = payload["attempt_id"] if payload["kind"] == "recovery" else None
117
+ if attempt is not None:
118
+ if attempt not in value.attempts and len(value.attempts) >= 5:
119
+ return False
120
+ value.attempts.add(attempt)
121
+ else:
122
+ if (
123
+ value.resource
124
+ or value.failure_id != payload["source_event_id"]
125
+ or value.failure_at is None
126
+ or now > value.failure_at + 30_000
127
+ ):
128
+ return False
129
+ value.resource = True
130
+ value.pending += 1
131
+ value.observations += 1
132
+ value.cost = _cost(key, value)
133
+ cost = size + len(event_id) + len(key) + 64
134
+ if self.accounted_bytes - (previous.cost if previous is not None else 0) + value.cost + cost > 65536:
135
+ return False
136
+ self._windows[key] = value
137
+ self._pending[event_id] = Pending(key, payload["kind"], attempt, size, cost)
138
+ self._held += cost
139
+ return True
140
+
141
+ def replace(self, original_id: str, event_id: str, payload: dict[str, Any], size: int, now: float) -> bool:
142
+ entry = self._pending.get(original_id)
143
+ key, attempt = _key(payload), payload.get("attempt_id")
144
+ if entry is not None and entry.scope == key and entry.kind == payload["kind"] and entry.attempt == attempt:
145
+ if event_id != original_id:
146
+ if event_id in self._pending:
147
+ self.release([original_id])
148
+ return False
149
+ self._pending[event_id] = self._pending.pop(original_id)
150
+ delta = len(event_id) - len(original_id)
151
+ entry.cost += delta
152
+ self._held += delta
153
+ if not self.adjust(event_id, size) or not self.eligible(event_id, payload):
154
+ self.release([event_id])
155
+ return False
156
+ return True
157
+ self.release([original_id])
158
+ return self.reserve(event_id, payload, size, now)
159
+
160
+ def adjust(self, event_id: str, size: int) -> bool:
161
+ entry = self._pending.get(event_id)
162
+ if entry is None or not _size(size) or self.accounted_bytes + size - entry.size > 65536:
163
+ return False
164
+ self._held += size - entry.size
165
+ entry.cost += size - entry.size
166
+ entry.size = size
167
+ return True
168
+
169
+ def eligible(self, event_id: str, payload: dict[str, Any]) -> bool:
170
+ entry, value = self._pending.get(event_id), self._windows.get(_key(payload))
171
+ return (
172
+ entry is not None
173
+ and entry.scope == _key(payload)
174
+ and value is not None
175
+ and not value.ambiguous
176
+ and (payload["kind"] == "recovery" or value.failure_id == payload["source_event_id"])
177
+ )
178
+
179
+ def release(self, event_ids: list[str]) -> None:
180
+ for event_id in event_ids:
181
+ entry = self._pending.pop(event_id, None)
182
+ if entry is None:
183
+ continue
184
+ self._held -= entry.cost
185
+ value = self._windows.get(entry.scope)
186
+ if value is not None:
187
+ value.pending = max(0, value.pending - 1)
188
+ value.cost = _cost(entry.scope, value)
189
+
190
+ def retain(self, event_ids: set[str]) -> None:
191
+ self.release([event_id for event_id in self._pending if event_id not in event_ids])