debugbundle-python 2.0.1__py3-none-any.whl → 2.1.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.
Files changed (39) hide show
  1. debugbundle/__init__.py +16 -0
  2. debugbundle/before_send.py +35 -191
  3. debugbundle/canonical_event.py +202 -0
  4. debugbundle/capture_hooks.py +14 -6
  5. debugbundle/config.py +6 -0
  6. debugbundle/core.py +76 -318
  7. debugbundle/delivery_hooks.py +14 -0
  8. debugbundle/diagnostic_budget.py +191 -0
  9. debugbundle/event-envelope.schema.json +4904 -0
  10. debugbundle/evidence_delivery.py +115 -0
  11. debugbundle/evidence_http.py +45 -0
  12. debugbundle/evidence_json.py +68 -0
  13. debugbundle/evidence_wire.py +409 -0
  14. debugbundle/exception_capture.py +98 -0
  15. debugbundle/exception_evidence.py +199 -0
  16. debugbundle/exception_identity.py +47 -0
  17. debugbundle/exception_snapshot.py +1 -1
  18. debugbundle/flush_delivery.py +203 -0
  19. debugbundle/ingestion_wire.py +75 -0
  20. debugbundle/integrations/relay_django.py +4 -1
  21. debugbundle/integrations/relay_fastapi.py +10 -2
  22. debugbundle/integrations/relay_flask.py +4 -1
  23. debugbundle/logger_integrations.py +16 -0
  24. debugbundle/operation_context.py +121 -0
  25. debugbundle/process_support.py +7 -0
  26. debugbundle/queue_admission.py +42 -2
  27. debugbundle/recovery_capture.py +43 -0
  28. debugbundle/relay.py +93 -36
  29. debugbundle/relay_delivery.py +108 -17
  30. debugbundle/remote_config_client.py +81 -0
  31. debugbundle/scope_ownership.py +99 -0
  32. debugbundle/structlog_integration.py +21 -0
  33. debugbundle/transport.py +20 -14
  34. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.1.dist-info}/METADATA +117 -1
  35. debugbundle_python-2.1.1.dist-info/RECORD +52 -0
  36. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.1.dist-info}/WHEEL +1 -1
  37. debugbundle_python-2.0.1.dist-info/RECORD +0 -36
  38. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.1.dist-info}/licenses/LICENSE +0 -0
  39. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.1.dist-info}/top_level.txt +0 -0
@@ -4,12 +4,17 @@ import json
4
4
  import os
5
5
  import re
6
6
  import secrets
7
+ import stat
7
8
  import threading
8
9
  import time
9
10
  from collections.abc import Callable, Mapping
10
11
  from dataclasses import dataclass
11
12
  from typing import Any
12
13
 
14
+ from .acknowledgement import decide_acknowledgement
15
+ from .evidence_delivery import EvidenceDelivery
16
+ from .evidence_wire import FEATURES, strip_unsupported
17
+ from .ingestion_wire import MAX_EVENT_BYTES, chunks, event_bytes, fit_event
13
18
  from .transport import HttpTransport, coerce_transport_response
14
19
 
15
20
  LOCAL_EVENTS_DIRECTORY_MODE = 0o700
@@ -38,8 +43,11 @@ def attach_project_token(events: list[dict[str, Any]], project_token: str) -> li
38
43
 
39
44
  def mark_spool_file_delivered(written_file_path: str) -> None:
40
45
  try:
41
- with open(f"{written_file_path}{RELAY_SPOOL_DELIVERED_MARKER_SUFFIX}", "w", encoding="utf-8"):
42
- pass
46
+ if not stat.S_ISREG(os.lstat(written_file_path).st_mode):
47
+ return
48
+ marker = f"{written_file_path}{RELAY_SPOOL_DELIVERED_MARKER_SUFFIX}"
49
+ fd = os.open(marker, os.O_CREAT | os.O_EXCL | os.O_WRONLY | OPTIONAL_NOFOLLOW_FLAG, LOCAL_EVENT_FILE_MODE)
50
+ os.close(fd)
43
51
  except OSError:
44
52
  # Durable acceptance already happened at the spool write; marker creation is maintenance metadata only.
45
53
  return
@@ -79,26 +87,109 @@ class AtomicRelayFileTransport:
79
87
 
80
88
 
81
89
  class RelayForwardTransport:
82
- def __init__(self, endpoint: str, transport: Callable[[Mapping[str, object]], object] | None = None) -> None:
90
+ def __init__(
91
+ self,
92
+ endpoint: str,
93
+ transport: Callable[[Mapping[str, object]], object] | None = None,
94
+ evidence: EvidenceDelivery | None = None,
95
+ ) -> None:
83
96
  self._transport = transport or HttpTransport(endpoint)
84
-
85
- def send(self, project_token: str, events: list[dict[str, Any]]) -> tuple[bool, bool]:
97
+ self._evidence = evidence or EvidenceDelivery()
98
+
99
+ @staticmethod
100
+ def prepare_event(event: dict[str, Any], project_token: str) -> dict[str, Any] | None:
101
+ owned = dict(event, project_token=project_token)
102
+ if event.get("event_type") == "analytics_event":
103
+ reduced = owned if event_bytes(owned) <= MAX_EVENT_BYTES else None
104
+ else:
105
+ reduced = fit_event(owned)
106
+ return {key: value for key, value in reduced.items() if key != "project_token"} if reduced else None
107
+
108
+ def send(self, project_token: str, events: list[dict[str, Any]], discover: bool = False) -> tuple[bool, bool]:
109
+ configured, success, _complete = self.send_verified(project_token, events, discover)
110
+ return configured, success
111
+
112
+ def send_verified(
113
+ self, project_token: str, events: list[dict[str, Any]], discover: bool = False
114
+ ) -> tuple[bool, bool, bool]:
86
115
  if not project_token:
87
- return (False, False)
116
+ return False, False, False
117
+ deadline = time.monotonic() + 5.0
118
+ try:
119
+ complete = True
120
+ # Start with the neutral intersection, so a previously unknown path
121
+ # can learn proof from this batch's canonical existing ACK traffic.
122
+ features = list(FEATURES)
123
+ for original in chunks(attach_project_token(events, project_token)):
124
+ if time.monotonic() >= deadline:
125
+ return True, False, False
126
+ batch: list[dict[str, Any]] = []
127
+ for event in original:
128
+ selected = (
129
+ strip_unsupported(event, self._evidence.features)
130
+ if event.get("event_type") != "analytics_event"
131
+ else event
132
+ )
133
+ if selected is None:
134
+ return True, False, False
135
+ batch.append(selected)
136
+ success, canonical = self._send_chunk(project_token, batch, discover, deadline)
137
+ features = [feature for feature in features if feature in self._evidence.features]
138
+ self._evidence.features = list(features)
139
+ if not success or time.monotonic() >= deadline:
140
+ return True, False, False
141
+ complete = complete and canonical
142
+ return True, True, complete
143
+ except Exception:
144
+ return True, False, False
88
145
 
146
+ def _send_chunk(
147
+ self, project_token: str, events: list[dict[str, Any]], discover: bool, deadline: float
148
+ ) -> tuple[bool, bool]:
89
149
  try:
90
- response = coerce_transport_response(
91
- self._transport(
92
- {
93
- "project_token": project_token,
94
- "events": attach_project_token(events, project_token),
95
- }
150
+
151
+ def send(batch: list[dict[str, Any]]):
152
+ if time.monotonic() >= deadline:
153
+ raise TimeoutError("relay forwarding deadline exhausted")
154
+ request = {"project_token": project_token, "events": batch}
155
+ response = (
156
+ self._transport.send_until(request, deadline)
157
+ if isinstance(self._transport, HttpTransport)
158
+ else self._transport(request)
96
159
  )
97
- )
160
+ return coerce_transport_response(response)
161
+
162
+ response = send(events)
163
+ if not 200 <= response.status_code < 300:
164
+ return False, False
165
+ required = discover or isinstance(self._transport, HttpTransport)
166
+ ack = decide_acknowledgement(response.body, len(events), required)
167
+ lost = self._evidence.observe(response.body, required, ack.kind == "acknowledged")
168
+ if ack.kind == "legacy":
169
+ return True, False
170
+ if ack.kind != "acknowledged":
171
+ return False, False
172
+ if ack.accepted == len(events):
173
+ return True, True
174
+ retries = []
175
+ if lost:
176
+ for index, reason in ack.terminal_errors:
177
+ original = events[index]
178
+ if reason == "invalid_event" and original.get("event_type") == "frontend_exception":
179
+ reduced = strip_unsupported(original, self._evidence.features)
180
+ if reduced is not None and reduced != original:
181
+ retries.append(reduced)
182
+ if not retries:
183
+ return False, False
184
+ response = send(retries)
185
+ if not 200 <= response.status_code < 300:
186
+ return False, False
187
+ retry_ack = decide_acknowledgement(response.body, len(retries), True)
188
+ self._evidence.observe(response.body, required, retry_ack.kind == "acknowledged")
189
+ complete = retry_ack.kind == "acknowledged" and ack.accepted + retry_ack.accepted == len(events)
190
+ return complete, complete
98
191
  except Exception:
99
- return (True, False)
100
-
101
- return (True, 200 <= response.status_code < 300)
192
+ return False, False
102
193
 
103
194
 
104
195
  def _sanitize_service_name(service_name: str) -> str:
@@ -135,4 +226,4 @@ def _cleanup_temp_files(events_dir: str) -> None:
135
226
  except OSError:
136
227
  continue
137
228
  except OSError:
138
- return
229
+ return
@@ -0,0 +1,81 @@
1
+ """Owned SDK boundary extracted without changing capture/delivery semantics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from .config import MINIMAL_CAPTURE_POLICY, parse_remote_config
8
+ from .event_support import sdk_config_endpoint, serialize_error
9
+ from .evidence_http import evidence_url
10
+
11
+ if TYPE_CHECKING:
12
+ from .core import DebugBundleSdk
13
+
14
+
15
+ def refresh_remote_config(sdk: DebugBundleSdk, initial: bool = False) -> None:
16
+ with sdk._lock:
17
+ if not sdk._enabled or sdk._fetch_impl is None:
18
+ return
19
+ request_headers: dict[str, str] = {"authorization": f"Bearer {sdk._project_token}"}
20
+ if sdk._remote_config_etag is not None:
21
+ request_headers["if-none-match"] = sdk._remote_config_etag
22
+ fetch = sdk._fetch_impl
23
+ endpoint = evidence_url(sdk_config_endpoint(sdk._endpoint))
24
+ poll_interval = sdk._configured_probes_poll_interval_ms
25
+ generation = sdk._generation
26
+
27
+ try:
28
+ response = fetch(endpoint, {"method": "GET", "headers": request_headers})
29
+ status_code = getattr(response, "status_code", None)
30
+ if status_code == 304:
31
+ snapshot = None
32
+ elif status_code == 200:
33
+ payload = response.json()
34
+ snapshot = parse_remote_config(
35
+ payload,
36
+ poll_interval,
37
+ int(sdk._time_provider() * 1000),
38
+ )
39
+ else:
40
+ raise RuntimeError(f"unexpected config status {status_code}")
41
+ headers = response.headers or {}
42
+ etag = headers.get("etag")
43
+ failure: BaseException | None = None
44
+ except BaseException as error:
45
+ status_code = None
46
+ snapshot = None
47
+ etag = None
48
+ failure = error
49
+
50
+ with sdk._lock:
51
+ if generation != sdk._generation or not sdk._enabled:
52
+ return
53
+ if failure is None and status_code == 304:
54
+ sdk._schedule_next_remote_config_refresh()
55
+ return
56
+ if failure is None:
57
+ sdk._evidence.observe(payload, True, snapshot is not None)
58
+ if snapshot is None:
59
+ sdk._emit_diagnostic(
60
+ "remote_probe_config_invalid",
61
+ "sdk-python received an invalid remote probe config payload",
62
+ )
63
+ if initial:
64
+ sdk._capture_policy = MINIMAL_CAPTURE_POLICY
65
+ sdk._schedule_next_remote_config_refresh(use_fallback=True)
66
+ return
67
+
68
+ sdk._remote_config_snapshot = snapshot
69
+ sdk._capture_policy = snapshot.capture_policy
70
+ if isinstance(etag, str) and len(etag) > 0:
71
+ sdk._remote_config_etag = etag
72
+ sdk._schedule_next_remote_config_refresh()
73
+ else:
74
+ sdk._emit_diagnostic(
75
+ "remote_probe_config_failed",
76
+ "sdk-python failed to refresh remote probe config",
77
+ metadata={"error": serialize_error(failure) if isinstance(failure, Exception) else "fetch_failed"},
78
+ )
79
+ if initial:
80
+ sdk._capture_policy = MINIMAL_CAPTURE_POLICY
81
+ sdk._schedule_next_remote_config_refresh(use_fallback=True)
@@ -0,0 +1,99 @@
1
+ """Revoke copied ContextVar values when their request/project owner ends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextvars import Token
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING, Any, cast
8
+
9
+ from .config import RemoteProbeDirective
10
+ from .operation_context import Operation
11
+
12
+ if TYPE_CHECKING:
13
+ from .core import DebugBundleSdk
14
+
15
+
16
+ @dataclass
17
+ class Lease:
18
+ generation: int
19
+ parent: Lease | None = None
20
+ active: bool = True
21
+ token_id: int = 0
22
+ operation: Token[Operation | None] | None = None
23
+
24
+
25
+ class ScopedContext(dict[str, object]):
26
+ lease: Lease
27
+
28
+
29
+ class ScopedDirectives(list[RemoteProbeDirective]):
30
+ lease: Lease
31
+
32
+
33
+ def alive(value: object, generation: int) -> bool:
34
+ if type(value) is not ScopedContext and type(value) is not ScopedDirectives:
35
+ return False
36
+ lease: Lease | None = value.lease
37
+ for _ in range(16):
38
+ if lease is None:
39
+ return True
40
+ if not lease.active or lease.generation != generation:
41
+ return False
42
+ lease = lease.parent
43
+ return False
44
+
45
+
46
+ def context_value(sdk: DebugBundleSdk) -> dict[str, object]:
47
+ value = sdk._scoped_context.get()
48
+ return value if alive(value, sdk._generation) and value is not None else {}
49
+
50
+
51
+ def bind_context(sdk: DebugBundleSdk, protected: dict[str, object]) -> Token[dict[str, object] | None]:
52
+ parent = sdk._scoped_context.get()
53
+ value = ScopedContext(protected)
54
+ value.lease = Lease(
55
+ sdk._generation, parent.lease if type(parent) is ScopedContext and alive(parent, sdk._generation) else None
56
+ )
57
+ token = sdk._scoped_context.set(value)
58
+ value.lease.token_id = id(token)
59
+ return token
60
+
61
+
62
+ def bind_directives(
63
+ sdk: DebugBundleSdk, directives: list[RemoteProbeDirective]
64
+ ) -> Token[list[RemoteProbeDirective] | None]:
65
+ parent = sdk._request_trigger_directives.get()
66
+ value = ScopedDirectives(directives)
67
+ value.lease = Lease(
68
+ sdk._generation,
69
+ parent.lease if type(parent) is ScopedDirectives and alive(parent, sdk._generation) else None,
70
+ operation=sdk._operations.begin({"kind": "request"}),
71
+ )
72
+ token = sdk._request_trigger_directives.set(value)
73
+ value.lease.token_id = id(token)
74
+ return token
75
+
76
+
77
+ def release(
78
+ sdk: DebugBundleSdk,
79
+ token: Token[dict[str, object] | None] | Token[list[RemoteProbeDirective] | None],
80
+ *,
81
+ directives: bool = False,
82
+ ) -> None:
83
+ variable = sdk._request_trigger_directives if directives else sdk._scoped_context
84
+ value = variable.get()
85
+ lease = value.lease if type(value) is ScopedContext or type(value) is ScopedDirectives else None
86
+ current_lease = lease
87
+ for _ in range(16):
88
+ if lease is None:
89
+ return
90
+ if lease.token_id == id(token):
91
+ if lease is current_lease:
92
+ # Preserve native Token context checks; old completion never clears
93
+ # the currently active newer owner.
94
+ cast(Any, variable).reset(token)
95
+ lease.active = False
96
+ if directives:
97
+ sdk._operations.end(lease.operation)
98
+ return
99
+ lease = lease.parent
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import copy
4
4
  import inspect
5
5
  import logging
6
+ import sys
6
7
  from contextvars import ContextVar
7
8
  from typing import Any
8
9
 
@@ -19,6 +20,17 @@ def _capture(sdk: Any, active: list[bool], name: str, record: dict[str, Any]) ->
19
20
  context = dict(record)
20
21
  message = str(context.pop("event", ""))
21
22
  level = {"warn": "warning", "exception": "error", "fatal": "critical"}.get(name, name)
23
+ info = context.get("exc_info")
24
+ if info is True:
25
+ info = sys.exc_info()
26
+ error = info[1] if type(info) is tuple and len(info) == 3 else info
27
+ if level in {"error", "critical"} and isinstance(error, BaseException) and hasattr(sdk, "capture_exception"):
28
+ eligible = getattr(sdk, "_log_level_eligible", None)
29
+ if callable(eligible) and not eligible(level):
30
+ return
31
+ context.pop("exc_info", None)
32
+ sdk.capture_exception(error, {**context, "logger_name": "structlog", "log_message": message})
33
+ return
22
34
  sdk.capture_log(message, level=level, context=context or None)
23
35
  except Exception:
24
36
  # A logger adapter must not throw into the application's logging path.
@@ -61,8 +73,10 @@ class StructlogLoggerProxy:
61
73
  if not callable(attribute):
62
74
  return attribute
63
75
  if name in _BIND_METHODS:
76
+
64
77
  def bind(*args: Any, **kwargs: Any) -> StructlogLoggerProxy:
65
78
  return StructlogLoggerProxy(attribute(*args, **kwargs), self._sdk, self._active)
79
+
66
80
  return bind
67
81
  method_name = name[1:] if name.startswith("a") else name
68
82
  if method_name not in _LOG_METHODS:
@@ -84,6 +98,7 @@ class StructlogLoggerProxy:
84
98
  if args:
85
99
  record["event"] = args[1] if method_name == "log" and len(args) > 1 else args[0]
86
100
  processors = list(bound._processors)
101
+ native_info = kwargs.get("exc_info", sys.exc_info() if method_name == "exception" else None)
87
102
  if processors:
88
103
  last = processors[-1]
89
104
 
@@ -96,6 +111,8 @@ class StructlogLoggerProxy:
96
111
  if isinstance(snapshot, dict):
97
112
  record.clear()
98
113
  record.update(snapshot)
114
+ if native_info is not None:
115
+ record["exc_info"] = native_info
99
116
  return result
100
117
 
101
118
  processors[-1] = observe
@@ -107,12 +124,15 @@ class StructlogLoggerProxy:
107
124
  return attribute
108
125
 
109
126
  if inspect.iscoroutinefunction(attribute):
127
+
110
128
  async def async_log(*args: Any, **kwargs: Any) -> Any:
111
129
  return await prepare(args, kwargs)(*args, **kwargs)
130
+
112
131
  return async_log
113
132
 
114
133
  def log(*args: Any, **kwargs: Any) -> Any:
115
134
  return prepare(args, kwargs)(*args, **kwargs)
135
+
116
136
  return log
117
137
 
118
138
  def _ensure_stdlib_handler(self, sink: Any) -> None:
@@ -136,4 +156,5 @@ class StructlogLoggerProxy:
136
156
  record = {**kwargs, **{f"arg_{i}": value for i, value in enumerate(args)}, "event": event or ""}
137
157
  _capture(self._sdk, self._active, name, record)
138
158
  return result
159
+
139
160
  return log
debugbundle/transport.py CHANGED
@@ -9,6 +9,9 @@ from typing import Any, Protocol
9
9
 
10
10
  import httpx
11
11
 
12
+ from .evidence_http import bounded_response_json, evidence_url
13
+ from .ingestion_wire import MAX_BODY_BYTES, encode_body
14
+
12
15
  MAX_RETRY_AFTER_MS = 300_000
13
16
 
14
17
 
@@ -44,8 +47,7 @@ class TransportResponse:
44
47
 
45
48
 
46
49
  class Transport(Protocol):
47
- def __call__(self, request: Mapping[str, object]) -> TransportResponse:
48
- ...
50
+ def __call__(self, request: Mapping[str, object]) -> TransportResponse: ...
49
51
 
50
52
 
51
53
  class HttpTransport:
@@ -54,22 +56,26 @@ class HttpTransport:
54
56
  self._client = httpx.Client(timeout=5.0)
55
57
 
56
58
  def __call__(self, request: Mapping[str, object]) -> TransportResponse:
59
+ return self.send_until(request, time.monotonic() + 5.0)
60
+
61
+ def send_until(self, request: Mapping[str, object], deadline: float) -> TransportResponse:
62
+ remaining = min(5.0, deadline - time.monotonic())
63
+ if remaining <= 0:
64
+ raise TimeoutError("ingestion deadline exhausted")
65
+ body_bytes = encode_body(request["events"])
66
+ if len(body_bytes) > MAX_BODY_BYTES:
67
+ raise ValueError("ingestion request exceeds byte limit")
57
68
  headers = {
58
69
  "authorization": f"Bearer {request['project_token']}",
59
70
  "content-type": "application/json",
71
+ "accept-encoding": "identity",
60
72
  }
61
- response = self._client.post(
62
- self._endpoint,
63
- json={"events": request["events"]},
64
- headers=headers,
65
- )
66
- retry_after_ms = _parse_retry_after(response.headers.get("retry-after"))
67
-
68
- try:
69
- body: object | None = response.json()
70
- except (ValueError, TypeError):
71
- body = None
72
- return TransportResponse(status_code=response.status_code, retry_after_ms=retry_after_ms, body=body)
73
+ with self._client.stream(
74
+ "POST", evidence_url(self._endpoint), content=body_bytes, headers=headers, timeout=remaining
75
+ ) as response:
76
+ retry_after_ms = _parse_retry_after(response.headers.get("retry-after"))
77
+ body = bounded_response_json(response, deadline)
78
+ return TransportResponse(status_code=response.status_code, retry_after_ms=retry_after_ms, body=body)
73
79
 
74
80
  def close(self) -> None:
75
81
  self._client.close()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 2.0.1
3
+ Version: 2.1.1
4
4
  Summary: DebugBundle SDK for Python
5
5
  Author: DebugBundle
6
6
  License-Expression: Apache-2.0
@@ -41,8 +41,18 @@ Dynamic: license-file
41
41
 
42
42
  # debugbundle-python
43
43
 
44
+ Patch 2.1.1 corrects native logger exception evidence and capture safety while preserving public APIs and event schemas. Upgrade the DebugBundle distribution to adopt the correction.
45
+
44
46
  Python SDK for DebugBundle.
45
47
 
48
+ After `make install-docker`, `make evidence-performance` compares identical
49
+ exception/recovery calls with evidence disabled and enabled, including a full
50
+ warning queue, with networking disabled. It reports capture timing separately from
51
+ Python `tracemalloc` peak/live allocation measurements, verifies native causes and
52
+ original-error priority, and checks existing retention ceilings. Setup and cleanup
53
+ are excluded; traced allocations are Python-managed memory, not cumulative churn
54
+ or total process RSS.
55
+
46
56
  ![PyPI](https://img.shields.io/pypi/v/debugbundle-python?label=pypi)
47
57
  ![CI](https://img.shields.io/github/actions/workflow/status/debugbundle/debugbundle-python/ci.yml?branch=main&label=ci)
48
58
  ![License](https://img.shields.io/badge/license-Apache--2.0-blue)
@@ -51,6 +61,93 @@ Use this package to capture Python backend exceptions, request metadata, structu
51
61
 
52
62
  Requires Python 3.10 or newer.
53
63
 
64
+ ## Evidence in 2.1.0
65
+
66
+ Version 2.1.0 adds `capture_exception_evidence=True` and
67
+ `capture_recovery_observations=False` installation options. Collection requires
68
+ verified delivery/reader support, discovered on existing config/ACK traffic with
69
+ `debugbundle_evidence=1`. No capture call performs discovery or adds polling.
70
+ `evidence_capabilities` is an optional descriptor pin asserting the **entire**
71
+ path, including local readers, relays and wrappers; later discovery can revoke it.
72
+ Without proof, exception events retain their legacy shape. These additions require upgrading from 2.0.1 to 2.1.0.
73
+
74
+ Native cause, implicit context and Python 3.11+ exception-group evidence excludes
75
+ locals, source excerpts, arbitrary renderers and application properties. Python
76
+ 3.10 has no native exception groups. Graphs retain at most eight nodes, depth
77
+ four, five children and 32 frames per node; all optional exception additions fit
78
+ 32 KiB. Uncaught native capture bypasses `before_send` and uses two nodes, eight
79
+ frames and a 4-KiB total including execution/reference metadata. The original
80
+ sanitised stack remains separate.
81
+
82
+ `with sdk.operation_context({"kind": "job", "name": "invoice.calculate",
83
+ "limit_ms": 5000}):` supplies a fresh operation scope and monotonic elapsed time
84
+ to synchronous or asynchronous work inside the block. Kinds are `request`,
85
+ `command`, `job` and `unknown`. The limit is a known configured limit, not time
86
+ remaining. Framework requests use their existing begin/end lifecycle. Completed
87
+ owners revoke copied async context; reinitialisation/fork discard prior ownership.
88
+ Application results and exceptions pass through the context manager unchanged.
89
+
90
+ The existing background sender remains best-effort at immediate process exit.
91
+ The uncaught hook collects bounded native evidence without waiting for transport;
92
+ applications needing a delivery attempt use explicit `flush()` in a controlled
93
+ shutdown window. This release does not add default crash-file persistence or
94
+ synchronous capture transport.
95
+
96
+ Pass a fresh UUID v4 `failure_ref` in exception/log/request context. A valid
97
+ `evidence_scope` can specify a fresh page or operation UUID; an active operation
98
+ provides the default. `capture_recovery({"failure_ref": failure_ref,
99
+ "attempt_id": attempt_id, "state": "attempt_completed"})` records an explicit
100
+ application observation. Optional fields are `evidence_scope`, technical
101
+ `operation`, `status_code`, `duration_ms` and primitive `correlation` metadata.
102
+ Page scopes require a nonblank session ID, including the valid string `"0"`.
103
+ Only explicit `recovery_observed` claims semantic recovery; HTTP 200 and
104
+ `attempt_completed` leave it unknown. The other states are `attempt_started`,
105
+ `attempt_failed` and `failure_persisted`.
106
+
107
+ Standalone recovery requires its local opt-in, `recovery_v1` support and server
108
+ `capture_diagnostic_context`; minimal policy disables it. Privacy, sampling,
109
+ resulting hook-event policy and queue limits still apply. Fifty failure windows,
110
+ five attempts and twenty observations per failure share 64 KiB of tracker and
111
+ pending-payload ownership, retained until ACK/drop even offline. Canonical hooks
112
+ may change any allowed identity/type field; ownership follows the replacement.
113
+ The complete closed envelope is checked against the vendored accepting Draft 7
114
+ schema, followed by evidence semantics and mandatory privacy. Only native JSON
115
+ containers/scalars are copied; custom serializers and container subclasses are
116
+ never invoked by hook validation. Invalid or throwing hooks keep the protected
117
+ original. Exhausted validation or an unfit valid replacement is withheld, so an
118
+ application redaction cannot be undone by a budget fallback.
119
+
120
+ Native HTTP requests select an ordered prefix by the actual compact UTF-8 JSON
121
+ serializer and remain within the receiving 256-KiB limit. Unsent finalized tails
122
+ stay charged and continue through the existing sender without repeating their
123
+ hooks. Optional SDK evidence is reduced before withholding an unfit primary;
124
+ explicit custom/local transports retain their established budgets. Browser
125
+ relays measure after server identity/token expansion, split downstream requests
126
+ and keep incomplete spools. A legacy custom success remains accepted but cannot
127
+ mark a durable spool delivered; that requires complete canonical acknowledgements.
128
+ Relay chunks share a five-second monotonic forwarding deadline, and spool markers
129
+ are created exclusively with private permissions without following symlinks.
130
+
131
+ Native HTTP ACK reads stop at 256 KiB and close the response. Requests ask for
132
+ identity encoding; unexpected compressed ACKs are withheld. The five-second
133
+ deadline is checked between streamed reads; HTTPX's synchronous per-read/connect/
134
+ write timeouts remain advisory and an in-progress native read can overrun that
135
+ deadline. Explicit custom transports/config fetchers retain their own timeout,
136
+ allocation and cancellation responsibilities. No additional timer thread or
137
+ inline capture transport is introduced.
138
+
139
+ Browser relays and Django/Flask/FastAPI relay factories accept the same optional
140
+ `evidence_capabilities` pin. They preserve closed DTOs, protected application
141
+ context and server-owned credentials, and advertise only verified downstream
142
+ support. Opted-in replies keep string `errors` and add indexed `evidence_errors`;
143
+ partial batches return 202, while legacy partial responses retain 400. A durable
144
+ spool accepts locally but is marked delivered only after a valid complete ACK.
145
+ Malformed ACKs cannot grant proof. Only negotiated `invalid_event` rejection of
146
+ an original exception can retry its stripped legacy form once; standalone
147
+ context, policy and authentication rejections do not use this fallback.
148
+ Framework relay adapters read at most the configured body limit plus one byte
149
+ before parsing, and preserve the negotiation query/header through the handler.
150
+
54
151
  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
152
 
56
153
  ## Automatic capture and application filtering
@@ -363,3 +460,22 @@ Apache-2.0. See `LICENSE`.
363
460
  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
461
 
365
462
  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.
463
+
464
+ ## Probe checkpoint guidance
465
+
466
+ Application instrumentation supplies probe labels and bounded data. Deploy a new
467
+ checkpoint before activating it. Remote activation affects future matching calls
468
+ when policy permits; it does not inject code or recover unretained measurements.
469
+ Built-in runtime/error/device collectors keep their documented SDK settings.
470
+
471
+ Use fixed labels and allowlisted technical states: `media.preview.checkpoint`
472
+ (stage/input kind/attempt), `auth.session.checkpoint` (observed session presence
473
+ and result), `job.dependency.checkpoint` (named dependency/attempt/result), or
474
+ `cache.preview.checkpoint` (hit/miss/coarse age bucket). Omit customer identifiers,
475
+ tokens/cookies, cache keys, URLs and raw payloads. HTTP 200 or attempt completion
476
+ alone does not establish semantic recovery.
477
+
478
+ Empty probe data means no matching retained observation for the selected sample
479
+ window. It does not confirm activation delivery to every SDK or execution of a
480
+ label. Check the deployed SDK/wrapper version, instrumentation, scope, expiry,
481
+ policy, sampling and delivery receipts. See the [probe recipes](https://debugbundle.com/docs/probes#bounded-checkpoint-recipes).
@@ -0,0 +1,52 @@
1
+ debugbundle/__init__.py,sha256=OFqqIibB3YlNeuTSynunM_XZ0cuts3Xoh15yNYDVEFs,5685
2
+ debugbundle/acknowledgement.py,sha256=2miOWbuIfElrbkktJHEuL7a2-IqKYUZCpO1UjSaouYw,2461
3
+ debugbundle/before_send.py,sha256=VpE056j_GRtJhhF9er-aLvR665fagPkvUlyXIJxrgbE,2124
4
+ debugbundle/canonical_event.py,sha256=qMwKCMsF-rHnYMnNVB2obDe66ykTXlM5S6chHSJODBI,8054
5
+ debugbundle/capture_hooks.py,sha256=tPZf-EdkI0hintjcFvaxw6KBPXFsMuusrNIqZVYkc_E,2279
6
+ debugbundle/config.py,sha256=3ZOYk35dEnZMqJOI_hvTGOzC17seVPUGxjnX05J15Do,9571
7
+ debugbundle/core.py,sha256=_oLk0FwW9vj1bxHDvW6cs47i0q6v4cjCsuA7k6N3Gr8,28285
8
+ debugbundle/delivery_hooks.py,sha256=hIXmFcKbHDw-3004Mc-pyz4iZGZNR-ZDiBw2xYKCTn4,3455
9
+ debugbundle/diagnostic_budget.py,sha256=dEfse2fqwYgR7IL6FASjqZ8cnq9lLlcwnLJ38utN0G0,7032
10
+ debugbundle/event-envelope.schema.json,sha256=QSAmE8Be_e91T_4_bDrBScdctEXABHpcsjMqyxZKeSI,148624
11
+ debugbundle/event_support.py,sha256=keOpma92Hxer2mOD9ELw-w5MRFYNOsj5A9C9goLdkDE,9046
12
+ debugbundle/evidence_delivery.py,sha256=cadrVo0lq5hNBLhl5woJof_HjAlrqrSgYjvBGfoUWVY,4781
13
+ debugbundle/evidence_http.py,sha256=k490I2Mh9L62A37Le1pnACb-9jVKIMFjsp9u0mYMhuA,1769
14
+ debugbundle/evidence_json.py,sha256=hAiENRzJBKUV-IujHDmK4qEEDtBkb8ZsBo9vyBmGwEo,2349
15
+ debugbundle/evidence_wire.py,sha256=nUY9SiiKoP9LhE31y6q_vyPE5_JieI-ZFDRYL_DX0dQ,16259
16
+ debugbundle/exception_capture.py,sha256=spw8iFQMC5x9Z5jf8zzjDG1BM65W6_WtW72cQUadEkU,4103
17
+ debugbundle/exception_evidence.py,sha256=cclp6aIuuqCM6HHKl4nHezZeswsQQhFZQn2s9fNtSVc,7517
18
+ debugbundle/exception_identity.py,sha256=i7Mb2AFOHlc-NO2eXpjjvpL7QKpeBsoyGAGb7RTXhU8,1781
19
+ debugbundle/exception_snapshot.py,sha256=Q3yX-rPuwyiP5tiyMCHnUE2r5Y5lne7bLb8s8abXtDw,2863
20
+ debugbundle/flush_aggregates.py,sha256=ukHsIUykeGp4YJsesA_9NCUMarg_BAXVuzO4bqJKH3Y,1464
21
+ debugbundle/flush_delivery.py,sha256=0gtluLskyKFWaKMPQSfd16tV256_rCvQrfV5muQOXdw,9009
22
+ debugbundle/ingestion_wire.py,sha256=SGyzAw4uGr9YRsB5hClVXb7il_nn3z0rm0Ofziw51qo,2537
23
+ debugbundle/logger_integrations.py,sha256=sxrw76zaM1sqMspEODSOukySwBdbEPNhsd3xdSsGD5g,4321
24
+ debugbundle/operation_context.py,sha256=oeY0W5su8VG1kWa4L8XrJZcNiAUZsGspjC5y-WGMXJQ,3783
25
+ debugbundle/probe_capture.py,sha256=c1a-ZKHyNeu6CMS5zgi8_wIfpLs5-pe12GEXNMpMTVM,3969
26
+ debugbundle/process_support.py,sha256=rKV_afnK-_tEcPO9Fzv2xvjN5Xt1sGwrZoF9yCsQ6_w,2273
27
+ debugbundle/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
+ debugbundle/queue_admission.py,sha256=NtXwpB8lZIu0sbtxJUNHXQp_AcgVNDnavEM80rz3raY,2970
29
+ debugbundle/recovery_capture.py,sha256=IOCarb9vmJ3ylwoYnD7GFTRwbra17dx7JCvuCA1isNY,1663
30
+ debugbundle/redaction.py,sha256=mxJgtILpVDUaj8LAhzswfaX1M4Ppyx87lfkhFqbMR8M,9663
31
+ debugbundle/relay.py,sha256=FVEvwrmMxdEDkTk3WDoNHFD0-_2w1OiYYHWAwQFVu9c,17664
32
+ debugbundle/relay_delivery.py,sha256=2EowBh_s5aF6ruPs1PjOXFAZ7IULRe5SBBg8v-bDf9o,9355
33
+ debugbundle/remote_config_client.py,sha256=WrAq-YzixEomJ98pTB05qo2ImfJKayc-HSYRYO1VJg0,3206
34
+ debugbundle/request_capture_policy.py,sha256=fl1EilWAnBQCdGTafRpY0tMlYUoSJK_7hZyJmPHLkeI,1583
35
+ debugbundle/scope_ownership.py,sha256=uB3dpG9blEwFcUbQNAu0jjHoxJlRoliRx7bx8F1YNVE,3141
36
+ debugbundle/structlog_integration.py,sha256=Y9yRikGH3ym_J2FBuxHl_pIVkpdfkaZ6Wh69PQHeLK8,6914
37
+ debugbundle/suppression.py,sha256=V_JvDvcV7tN3EHkfpEJGK1WB2G4nR3GhXvaKd400iFQ,4418
38
+ debugbundle/transport.py,sha256=-MkMFf2rjPrDEGLVe-LHewFtkvVlA40X7XDaHKxVeqE,3343
39
+ debugbundle/trigger_token.py,sha256=YUwIWnnxu1klAVaB8Ltgk_PwWW_PPjq9rzmEbWXeFeM,4455
40
+ debugbundle/integrations/__init__.py,sha256=Jr87ImWXzUXOuFP4WlGleK14VARajSSdGZN_nHt5emw,1161
41
+ debugbundle/integrations/common.py,sha256=iiwf5wDlpujFuWfbO-ziFTn4MtiHBXSIZNXtamhCavg,2014
42
+ debugbundle/integrations/django.py,sha256=vDQGc0ObcHCe4NQbC0ijcXUM-y9GzOVBjDtcHzxyrxw,1828
43
+ debugbundle/integrations/fastapi.py,sha256=-054Z6MogkZIqKR8DHR4jm2nNC5yyZ5vtanpw6g-voE,3414
44
+ debugbundle/integrations/flask.py,sha256=cgyHbsAH96gpNPf_5IGJYzp2va28JdDgXROBsF-Fbyo,2390
45
+ debugbundle/integrations/relay_django.py,sha256=KCo9-5eumVZZKEmzZemL4srCRN7inMB5aeOSUqeEEXE,2378
46
+ debugbundle/integrations/relay_fastapi.py,sha256=oVL5m8SFsbcbxzmDVefGsBk75Ha2DOSymJwDPmHOI-o,2551
47
+ debugbundle/integrations/relay_flask.py,sha256=GPiXNR3feH-KU5GJ8tguOvt0Rr9b776GY8O2SjiLwQs,2169
48
+ debugbundle_python-2.1.1.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
49
+ debugbundle_python-2.1.1.dist-info/METADATA,sha256=TqEiBy0ZO9NdlwuRnCmLq4wYuFlV7hAPYyFNryu6dR0,23904
50
+ debugbundle_python-2.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
51
+ debugbundle_python-2.1.1.dist-info/top_level.txt,sha256=RCB9STTFnl1OKdojxz-xhaks2zkRFs1meZXsKnm18LM,12
52
+ debugbundle_python-2.1.1.dist-info/RECORD,,