debugbundle-python 2.0.0__tar.gz → 2.1.0__tar.gz

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 (84) hide show
  1. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/PKG-INFO +122 -1
  2. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/README.md +121 -0
  3. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/pyproject.toml +4 -1
  4. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/__init__.py +16 -0
  5. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/acknowledgement.py +3 -1
  6. debugbundle_python-2.1.0/src/debugbundle/before_send.py +62 -0
  7. debugbundle_python-2.1.0/src/debugbundle/canonical_event.py +202 -0
  8. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/config.py +6 -0
  9. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/core.py +76 -316
  10. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/delivery_hooks.py +14 -0
  11. debugbundle_python-2.1.0/src/debugbundle/diagnostic_budget.py +191 -0
  12. debugbundle_python-2.1.0/src/debugbundle/event-envelope.schema.json +4904 -0
  13. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/event_support.py +1 -1
  14. debugbundle_python-2.1.0/src/debugbundle/evidence_delivery.py +115 -0
  15. debugbundle_python-2.1.0/src/debugbundle/evidence_http.py +45 -0
  16. debugbundle_python-2.1.0/src/debugbundle/evidence_json.py +68 -0
  17. debugbundle_python-2.1.0/src/debugbundle/evidence_wire.py +409 -0
  18. debugbundle_python-2.1.0/src/debugbundle/exception_capture.py +92 -0
  19. debugbundle_python-2.1.0/src/debugbundle/exception_evidence.py +199 -0
  20. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/exception_snapshot.py +1 -1
  21. debugbundle_python-2.1.0/src/debugbundle/flush_delivery.py +203 -0
  22. debugbundle_python-2.1.0/src/debugbundle/ingestion_wire.py +75 -0
  23. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/integrations/relay_django.py +4 -1
  24. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/integrations/relay_fastapi.py +10 -2
  25. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/integrations/relay_flask.py +4 -1
  26. debugbundle_python-2.1.0/src/debugbundle/operation_context.py +121 -0
  27. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/process_support.py +7 -0
  28. debugbundle_python-2.1.0/src/debugbundle/queue_admission.py +79 -0
  29. debugbundle_python-2.1.0/src/debugbundle/recovery_capture.py +43 -0
  30. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/relay.py +93 -36
  31. debugbundle_python-2.1.0/src/debugbundle/relay_delivery.py +229 -0
  32. debugbundle_python-2.1.0/src/debugbundle/remote_config_client.py +81 -0
  33. debugbundle_python-2.1.0/src/debugbundle/scope_ownership.py +99 -0
  34. debugbundle_python-2.1.0/src/debugbundle/transport.py +94 -0
  35. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle_python.egg-info/PKG-INFO +122 -1
  36. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle_python.egg-info/SOURCES.txt +20 -0
  37. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_before_send.py +96 -5
  38. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_contracts.py +3 -0
  39. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_delivery_hooks.py +26 -5
  40. debugbundle_python-2.1.0/tests/test_evidence_capture.py +272 -0
  41. debugbundle_python-2.1.0/tests/test_evidence_wire.py +198 -0
  42. debugbundle_python-2.1.0/tests/test_exception_evidence.py +125 -0
  43. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_exception_snapshot.py +16 -0
  44. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_framework_integrations.py +48 -9
  45. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_http_integration.py +2 -4
  46. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_relay.py +9 -1
  47. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_relay_delivery.py +16 -1
  48. debugbundle_python-2.1.0/tests/test_relay_evidence.py +215 -0
  49. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_remote_config.py +16 -7
  50. debugbundle_python-2.1.0/tests/test_transport_reliability.py +286 -0
  51. debugbundle_python-2.0.0/src/debugbundle/before_send.py +0 -218
  52. debugbundle_python-2.0.0/src/debugbundle/queue_admission.py +0 -39
  53. debugbundle_python-2.0.0/src/debugbundle/relay_delivery.py +0 -138
  54. debugbundle_python-2.0.0/src/debugbundle/transport.py +0 -65
  55. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/LICENSE +0 -0
  56. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/setup.cfg +0 -0
  57. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/capture_hooks.py +0 -0
  58. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/flush_aggregates.py +0 -0
  59. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/integrations/__init__.py +0 -0
  60. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/integrations/common.py +0 -0
  61. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/integrations/django.py +0 -0
  62. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/integrations/fastapi.py +0 -0
  63. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/integrations/flask.py +0 -0
  64. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/logger_integrations.py +0 -0
  65. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/probe_capture.py +0 -0
  66. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/py.typed +0 -0
  67. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/redaction.py +0 -0
  68. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/request_capture_policy.py +0 -0
  69. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/structlog_integration.py +0 -0
  70. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/suppression.py +0 -0
  71. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle/trigger_token.py +0 -0
  72. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle_python.egg-info/dependency_links.txt +0 -0
  73. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle_python.egg-info/requires.txt +0 -0
  74. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/src/debugbundle_python.egg-info/top_level.txt +0 -0
  75. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_hooks.py +0 -0
  76. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_optional_imports.py +0 -0
  77. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_privacy_conformance.py +0 -0
  78. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_repository_metadata.py +0 -0
  79. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_schema_validation.py +0 -0
  80. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_sdk.py +0 -0
  81. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_smoke_script.py +0 -0
  82. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_structlog_filtering.py +0 -0
  83. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_suppression.py +0 -0
  84. {debugbundle_python-2.0.0 → debugbundle_python-2.1.0}/tests/test_trigger_token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: DebugBundle SDK for Python
5
5
  Author: DebugBundle
6
6
  License-Expression: Apache-2.0
@@ -43,6 +43,14 @@ Dynamic: license-file
43
43
 
44
44
  Python SDK for DebugBundle.
45
45
 
46
+ After `make install-docker`, `make evidence-performance` compares identical
47
+ exception/recovery calls with evidence disabled and enabled, including a full
48
+ warning queue, with networking disabled. It reports capture timing separately from
49
+ Python `tracemalloc` peak/live allocation measurements, verifies native causes and
50
+ original-error priority, and checks existing retention ceilings. Setup and cleanup
51
+ are excluded; traced allocations are Python-managed memory, not cumulative churn
52
+ or total process RSS.
53
+
46
54
  ![PyPI](https://img.shields.io/pypi/v/debugbundle-python?label=pypi)
47
55
  ![CI](https://img.shields.io/github/actions/workflow/status/debugbundle/debugbundle-python/ci.yml?branch=main&label=ci)
48
56
  ![License](https://img.shields.io/badge/license-Apache--2.0-blue)
@@ -51,6 +59,94 @@ Use this package to capture Python backend exceptions, request metadata, structu
51
59
 
52
60
  Requires Python 3.10 or newer.
53
61
 
62
+ ## Evidence candidate (unreleased)
63
+
64
+ The source candidate adds `capture_exception_evidence=True` and
65
+ `capture_recovery_observations=False` installation options. Collection requires
66
+ verified delivery/reader support, discovered on existing config/ACK traffic with
67
+ `debugbundle_evidence=1`. No capture call performs discovery or adds polling.
68
+ `evidence_capabilities` is an optional descriptor pin asserting the **entire**
69
+ path, including local readers, relays and wrappers; later discovery can revoke it.
70
+ Without proof, exception events retain their legacy shape. These additions are
71
+ not available in the installed 2.0.1 release.
72
+
73
+ Native cause, implicit context and Python 3.11+ exception-group evidence excludes
74
+ locals, source excerpts, arbitrary renderers and application properties. Python
75
+ 3.10 has no native exception groups. Graphs retain at most eight nodes, depth
76
+ four, five children and 32 frames per node; all optional exception additions fit
77
+ 32 KiB. Uncaught native capture bypasses `before_send` and uses two nodes, eight
78
+ frames and a 4-KiB total including execution/reference metadata. The original
79
+ sanitised stack remains separate.
80
+
81
+ `with sdk.operation_context({"kind": "job", "name": "invoice.calculate",
82
+ "limit_ms": 5000}):` supplies a fresh operation scope and monotonic elapsed time
83
+ to synchronous or asynchronous work inside the block. Kinds are `request`,
84
+ `command`, `job` and `unknown`. The limit is a known configured limit, not time
85
+ remaining. Framework requests use their existing begin/end lifecycle. Completed
86
+ owners revoke copied async context; reinitialisation/fork discard prior ownership.
87
+ Application results and exceptions pass through the context manager unchanged.
88
+
89
+ The existing background sender remains best-effort at immediate process exit.
90
+ The uncaught hook collects bounded native evidence without waiting for transport;
91
+ applications needing a delivery attempt use explicit `flush()` in a controlled
92
+ shutdown window. This candidate does not add default crash-file persistence or
93
+ synchronous capture transport.
94
+
95
+ Pass a fresh UUID v4 `failure_ref` in exception/log/request context. A valid
96
+ `evidence_scope` can specify a fresh page or operation UUID; an active operation
97
+ provides the default. `capture_recovery({"failure_ref": failure_ref,
98
+ "attempt_id": attempt_id, "state": "attempt_completed"})` records an explicit
99
+ application observation. Optional fields are `evidence_scope`, technical
100
+ `operation`, `status_code`, `duration_ms` and primitive `correlation` metadata.
101
+ Page scopes require a nonblank session ID, including the valid string `"0"`.
102
+ Only explicit `recovery_observed` claims semantic recovery; HTTP 200 and
103
+ `attempt_completed` leave it unknown. The other states are `attempt_started`,
104
+ `attempt_failed` and `failure_persisted`.
105
+
106
+ Standalone recovery requires its local opt-in, `recovery_v1` support and server
107
+ `capture_diagnostic_context`; minimal policy disables it. Privacy, sampling,
108
+ resulting hook-event policy and queue limits still apply. Fifty failure windows,
109
+ five attempts and twenty observations per failure share 64 KiB of tracker and
110
+ pending-payload ownership, retained until ACK/drop even offline. Canonical hooks
111
+ may change any allowed identity/type field; ownership follows the replacement.
112
+ The complete closed envelope is checked against the vendored accepting Draft 7
113
+ schema, followed by evidence semantics and mandatory privacy. Only native JSON
114
+ containers/scalars are copied; custom serializers and container subclasses are
115
+ never invoked by hook validation. Invalid or throwing hooks keep the protected
116
+ original. Exhausted validation or an unfit valid replacement is withheld, so an
117
+ application redaction cannot be undone by a budget fallback.
118
+
119
+ Native HTTP requests select an ordered prefix by the actual compact UTF-8 JSON
120
+ serializer and remain within the receiving 256-KiB limit. Unsent finalized tails
121
+ stay charged and continue through the existing sender without repeating their
122
+ hooks. Optional SDK evidence is reduced before withholding an unfit primary;
123
+ explicit custom/local transports retain their established budgets. Browser
124
+ relays measure after server identity/token expansion, split downstream requests
125
+ and keep incomplete spools. A legacy custom success remains accepted but cannot
126
+ mark a durable spool delivered; that requires complete canonical acknowledgements.
127
+ Relay chunks share a five-second monotonic forwarding deadline, and spool markers
128
+ are created exclusively with private permissions without following symlinks.
129
+
130
+ Native HTTP ACK reads stop at 256 KiB and close the response. Requests ask for
131
+ identity encoding; unexpected compressed ACKs are withheld. The five-second
132
+ deadline is checked between streamed reads; HTTPX's synchronous per-read/connect/
133
+ write timeouts remain advisory and an in-progress native read can overrun that
134
+ deadline. Explicit custom transports/config fetchers retain their own timeout,
135
+ allocation and cancellation responsibilities. No additional timer thread or
136
+ inline capture transport is introduced.
137
+
138
+ Browser relays and Django/Flask/FastAPI relay factories accept the same optional
139
+ `evidence_capabilities` pin. They preserve closed DTOs, protected application
140
+ context and server-owned credentials, and advertise only verified downstream
141
+ support. Opted-in replies keep string `errors` and add indexed `evidence_errors`;
142
+ partial batches return 202, while legacy partial responses retain 400. A durable
143
+ spool accepts locally but is marked delivered only after a valid complete ACK.
144
+ Malformed ACKs cannot grant proof. Only negotiated `invalid_event` rejection of
145
+ an original exception can retry its stripped legacy form once; standalone
146
+ context, policy and authentication rejections do not use this fallback.
147
+ Framework relay adapters read at most the configured body limit plus one byte
148
+ before parsing, and preserve the negotiation query/header through the handler.
149
+
54
150
  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
151
 
56
152
  ## Automatic capture and application filtering
@@ -357,3 +453,28 @@ CI validates Ruff, mypy, pytest, package build, event schema fixtures, and cover
357
453
  ## License
358
454
 
359
455
  Apache-2.0. See `LICENSE`.
456
+
457
+ ## Delivery acknowledgement and retry limits
458
+
459
+ 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`.
460
+
461
+ 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.
462
+
463
+ ## Probe checkpoint guidance
464
+
465
+ Application instrumentation supplies probe labels and bounded data. Deploy a new
466
+ checkpoint before activating it. Remote activation affects future matching calls
467
+ when policy permits; it does not inject code or recover unretained measurements.
468
+ Built-in runtime/error/device collectors keep their documented SDK settings.
469
+
470
+ Use fixed labels and allowlisted technical states: `media.preview.checkpoint`
471
+ (stage/input kind/attempt), `auth.session.checkpoint` (observed session presence
472
+ and result), `job.dependency.checkpoint` (named dependency/attempt/result), or
473
+ `cache.preview.checkpoint` (hit/miss/coarse age bucket). Omit customer identifiers,
474
+ tokens/cookies, cache keys, URLs and raw payloads. HTTP 200 or attempt completion
475
+ alone does not establish semantic recovery.
476
+
477
+ Empty probe data means no matching retained observation for the selected sample
478
+ window. It does not confirm activation delivery to every SDK or execution of a
479
+ label. Check the deployed SDK/wrapper version, instrumentation, scope, expiry,
480
+ policy, sampling and delivery receipts. See the [probe recipes](https://debugbundle.com/docs/probes#bounded-checkpoint-recipes).
@@ -2,6 +2,14 @@
2
2
 
3
3
  Python SDK for DebugBundle.
4
4
 
5
+ After `make install-docker`, `make evidence-performance` compares identical
6
+ exception/recovery calls with evidence disabled and enabled, including a full
7
+ warning queue, with networking disabled. It reports capture timing separately from
8
+ Python `tracemalloc` peak/live allocation measurements, verifies native causes and
9
+ original-error priority, and checks existing retention ceilings. Setup and cleanup
10
+ are excluded; traced allocations are Python-managed memory, not cumulative churn
11
+ or total process RSS.
12
+
5
13
  ![PyPI](https://img.shields.io/pypi/v/debugbundle-python?label=pypi)
6
14
  ![CI](https://img.shields.io/github/actions/workflow/status/debugbundle/debugbundle-python/ci.yml?branch=main&label=ci)
7
15
  ![License](https://img.shields.io/badge/license-Apache--2.0-blue)
@@ -10,6 +18,94 @@ Use this package to capture Python backend exceptions, request metadata, structu
10
18
 
11
19
  Requires Python 3.10 or newer.
12
20
 
21
+ ## Evidence candidate (unreleased)
22
+
23
+ The source candidate adds `capture_exception_evidence=True` and
24
+ `capture_recovery_observations=False` installation options. Collection requires
25
+ verified delivery/reader support, discovered on existing config/ACK traffic with
26
+ `debugbundle_evidence=1`. No capture call performs discovery or adds polling.
27
+ `evidence_capabilities` is an optional descriptor pin asserting the **entire**
28
+ path, including local readers, relays and wrappers; later discovery can revoke it.
29
+ Without proof, exception events retain their legacy shape. These additions are
30
+ not available in the installed 2.0.1 release.
31
+
32
+ Native cause, implicit context and Python 3.11+ exception-group evidence excludes
33
+ locals, source excerpts, arbitrary renderers and application properties. Python
34
+ 3.10 has no native exception groups. Graphs retain at most eight nodes, depth
35
+ four, five children and 32 frames per node; all optional exception additions fit
36
+ 32 KiB. Uncaught native capture bypasses `before_send` and uses two nodes, eight
37
+ frames and a 4-KiB total including execution/reference metadata. The original
38
+ sanitised stack remains separate.
39
+
40
+ `with sdk.operation_context({"kind": "job", "name": "invoice.calculate",
41
+ "limit_ms": 5000}):` supplies a fresh operation scope and monotonic elapsed time
42
+ to synchronous or asynchronous work inside the block. Kinds are `request`,
43
+ `command`, `job` and `unknown`. The limit is a known configured limit, not time
44
+ remaining. Framework requests use their existing begin/end lifecycle. Completed
45
+ owners revoke copied async context; reinitialisation/fork discard prior ownership.
46
+ Application results and exceptions pass through the context manager unchanged.
47
+
48
+ The existing background sender remains best-effort at immediate process exit.
49
+ The uncaught hook collects bounded native evidence without waiting for transport;
50
+ applications needing a delivery attempt use explicit `flush()` in a controlled
51
+ shutdown window. This candidate does not add default crash-file persistence or
52
+ synchronous capture transport.
53
+
54
+ Pass a fresh UUID v4 `failure_ref` in exception/log/request context. A valid
55
+ `evidence_scope` can specify a fresh page or operation UUID; an active operation
56
+ provides the default. `capture_recovery({"failure_ref": failure_ref,
57
+ "attempt_id": attempt_id, "state": "attempt_completed"})` records an explicit
58
+ application observation. Optional fields are `evidence_scope`, technical
59
+ `operation`, `status_code`, `duration_ms` and primitive `correlation` metadata.
60
+ Page scopes require a nonblank session ID, including the valid string `"0"`.
61
+ Only explicit `recovery_observed` claims semantic recovery; HTTP 200 and
62
+ `attempt_completed` leave it unknown. The other states are `attempt_started`,
63
+ `attempt_failed` and `failure_persisted`.
64
+
65
+ Standalone recovery requires its local opt-in, `recovery_v1` support and server
66
+ `capture_diagnostic_context`; minimal policy disables it. Privacy, sampling,
67
+ resulting hook-event policy and queue limits still apply. Fifty failure windows,
68
+ five attempts and twenty observations per failure share 64 KiB of tracker and
69
+ pending-payload ownership, retained until ACK/drop even offline. Canonical hooks
70
+ may change any allowed identity/type field; ownership follows the replacement.
71
+ The complete closed envelope is checked against the vendored accepting Draft 7
72
+ schema, followed by evidence semantics and mandatory privacy. Only native JSON
73
+ containers/scalars are copied; custom serializers and container subclasses are
74
+ never invoked by hook validation. Invalid or throwing hooks keep the protected
75
+ original. Exhausted validation or an unfit valid replacement is withheld, so an
76
+ application redaction cannot be undone by a budget fallback.
77
+
78
+ Native HTTP requests select an ordered prefix by the actual compact UTF-8 JSON
79
+ serializer and remain within the receiving 256-KiB limit. Unsent finalized tails
80
+ stay charged and continue through the existing sender without repeating their
81
+ hooks. Optional SDK evidence is reduced before withholding an unfit primary;
82
+ explicit custom/local transports retain their established budgets. Browser
83
+ relays measure after server identity/token expansion, split downstream requests
84
+ and keep incomplete spools. A legacy custom success remains accepted but cannot
85
+ mark a durable spool delivered; that requires complete canonical acknowledgements.
86
+ Relay chunks share a five-second monotonic forwarding deadline, and spool markers
87
+ are created exclusively with private permissions without following symlinks.
88
+
89
+ Native HTTP ACK reads stop at 256 KiB and close the response. Requests ask for
90
+ identity encoding; unexpected compressed ACKs are withheld. The five-second
91
+ deadline is checked between streamed reads; HTTPX's synchronous per-read/connect/
92
+ write timeouts remain advisory and an in-progress native read can overrun that
93
+ deadline. Explicit custom transports/config fetchers retain their own timeout,
94
+ allocation and cancellation responsibilities. No additional timer thread or
95
+ inline capture transport is introduced.
96
+
97
+ Browser relays and Django/Flask/FastAPI relay factories accept the same optional
98
+ `evidence_capabilities` pin. They preserve closed DTOs, protected application
99
+ context and server-owned credentials, and advertise only verified downstream
100
+ support. Opted-in replies keep string `errors` and add indexed `evidence_errors`;
101
+ partial batches return 202, while legacy partial responses retain 400. A durable
102
+ spool accepts locally but is marked delivered only after a valid complete ACK.
103
+ Malformed ACKs cannot grant proof. Only negotiated `invalid_event` rejection of
104
+ an original exception can retry its stripped legacy form once; standalone
105
+ context, policy and authentication rejections do not use this fallback.
106
+ Framework relay adapters read at most the configured body limit plus one byte
107
+ before parsing, and preserve the negotiation query/header through the handler.
108
+
13
109
  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.
14
110
 
15
111
  ## Automatic capture and application filtering
@@ -316,3 +412,28 @@ CI validates Ruff, mypy, pytest, package build, event schema fixtures, and cover
316
412
  ## License
317
413
 
318
414
  Apache-2.0. See `LICENSE`.
415
+
416
+ ## Delivery acknowledgement and retry limits
417
+
418
+ 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`.
419
+
420
+ 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.
421
+
422
+ ## Probe checkpoint guidance
423
+
424
+ Application instrumentation supplies probe labels and bounded data. Deploy a new
425
+ checkpoint before activating it. Remote activation affects future matching calls
426
+ when policy permits; it does not inject code or recover unretained measurements.
427
+ Built-in runtime/error/device collectors keep their documented SDK settings.
428
+
429
+ Use fixed labels and allowlisted technical states: `media.preview.checkpoint`
430
+ (stage/input kind/attempt), `auth.session.checkpoint` (observed session presence
431
+ and result), `job.dependency.checkpoint` (named dependency/attempt/result), or
432
+ `cache.preview.checkpoint` (hit/miss/coarse age bucket). Omit customer identifiers,
433
+ tokens/cookies, cache keys, URLs and raw payloads. HTTP 200 or attempt completion
434
+ alone does not establish semantic recovery.
435
+
436
+ Empty probe data means no matching retained observation for the selected sample
437
+ window. It does not confirm activation delivery to every SDK or execution of a
438
+ label. Check the deployed SDK/wrapper version, instrumentation, scope, expiry,
439
+ policy, sampling and delivery receipts. See the [probe recipes](https://debugbundle.com/docs/probes#bounded-checkpoint-recipes).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "debugbundle-python"
7
- version = "2.0.0"
7
+ version = "2.1.0"
8
8
  description = "DebugBundle SDK for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -54,6 +54,9 @@ Issues = "https://github.com/debugbundle/debugbundle-python/issues"
54
54
  package-dir = {"" = "src"}
55
55
  include-package-data = true
56
56
 
57
+ [tool.setuptools.package-data]
58
+ debugbundle = ["event-envelope.schema.json", "py.typed"]
59
+
57
60
  [tool.setuptools.packages.find]
58
61
  where = ["src"]
59
62
 
@@ -33,6 +33,9 @@ def init(
33
33
  on_diagnostic: Callable[[dict[str, object]], None] | None = None,
34
34
  before_send: BeforeSendHook | None = None,
35
35
  probes_poll_interval: int = 60_000,
36
+ capture_exception_evidence: bool = True,
37
+ evidence_capabilities: object = None,
38
+ capture_recovery_observations: bool = False,
36
39
  ) -> None:
37
40
  _sdk.init(
38
41
  project_token=project_token,
@@ -52,6 +55,9 @@ def init(
52
55
  on_diagnostic=on_diagnostic,
53
56
  before_send=before_send,
54
57
  probes_poll_interval=probes_poll_interval,
58
+ capture_exception_evidence=capture_exception_evidence,
59
+ evidence_capabilities=evidence_capabilities,
60
+ capture_recovery_observations=capture_recovery_observations,
55
61
  )
56
62
 
57
63
 
@@ -63,6 +69,14 @@ def capture_error(error: BaseException, context: dict[str, object] | None = None
63
69
  _sdk.capture_error(error, context=context)
64
70
 
65
71
 
72
+ def operation_context(options: object) -> Any:
73
+ return _sdk.operation_context(options)
74
+
75
+
76
+ def capture_recovery(observation: object) -> None:
77
+ _sdk.capture_recovery(observation)
78
+
79
+
66
80
  def capture_log(message: str, level: str = "warning", context: dict[str, object] | None = None) -> None:
67
81
  _sdk.capture_log(message, level=level, context=context)
68
82
 
@@ -155,6 +169,7 @@ __all__ = [
155
169
  "capture_logging",
156
170
  "capture_message",
157
171
  "capture_request",
172
+ "capture_recovery",
158
173
  "create_django_relay_view",
159
174
  "create_fastapi_relay_handler",
160
175
  "create_flask_relay_handler",
@@ -164,5 +179,6 @@ __all__ = [
164
179
  "instrument_fastapi",
165
180
  "instrument_flask",
166
181
  "probe",
182
+ "operation_context",
167
183
  "set_context",
168
184
  ]
@@ -19,8 +19,10 @@ class AcknowledgementDecision:
19
19
  reason: str | None = None
20
20
 
21
21
 
22
- def decide_acknowledgement(body: object | None, batch_length: int) -> AcknowledgementDecision:
22
+ def decide_acknowledgement(body: object | None, batch_length: int, required: bool = False) -> AcknowledgementDecision:
23
23
  if not isinstance(body, dict) or not any(key in body for key in ("accepted", "rejected", "errors")):
24
+ if required:
25
+ return AcknowledgementDecision(kind="protocol_failure", reason="missing_acknowledgement")
24
26
  return AcknowledgementDecision(kind="legacy")
25
27
 
26
28
  accepted = body.get("accepted")
@@ -0,0 +1,62 @@
1
+ """Canonical, owned hook results with explicit invalid and exhausted outcomes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ from collections.abc import Callable
7
+ from typing import cast
8
+
9
+ from .canonical_event import EXHAUSTED, INVALID, ValidationBudgetExceeded, own_json, valid_owned_event
10
+ from .evidence_wire import validate_evidence
11
+
12
+ BeforeSendHook = Callable[[dict[str, object]], dict[str, object] | None]
13
+ _MOBILE = ("@debugbundle/sdk-android", "@debugbundle/sdk-swift", "@debugbundle/sdk-react-native")
14
+
15
+
16
+ def _normalized(event: object) -> object:
17
+ if type(event) is not dict:
18
+ return INVALID
19
+ owned = own_json(event)
20
+ if owned is INVALID or owned is EXHAUSTED:
21
+ return owned
22
+ try:
23
+ if not valid_owned_event(owned) or not validate_evidence(owned):
24
+ return INVALID
25
+ # The accepting Zod contract refines this condition outside JSON Schema.
26
+ if owned["event_type"] == "frontend_exception":
27
+ field = "device" if owned["sdk_name"] in _MOBILE else "browser"
28
+ if field not in owned["payload"] or type(owned["payload"][field]) is not dict:
29
+ return INVALID
30
+ return owned
31
+ except ValidationBudgetExceeded:
32
+ return EXHAUSTED
33
+ except Exception:
34
+ return INVALID
35
+
36
+
37
+ def apply_before_send(
38
+ event: dict[str, object],
39
+ hook: BeforeSendHook | None,
40
+ emit_diagnostic: Callable[[str, str], None],
41
+ ) -> dict[str, object] | None:
42
+ if hook is None:
43
+ return event
44
+ try:
45
+ result = hook(copy.deepcopy(event))
46
+ except Exception:
47
+ emit_diagnostic("before_send_failed", "Python before_send hook failed")
48
+ return event
49
+ if result is None:
50
+ return None
51
+ checked = _normalized(result)
52
+ if checked is EXHAUSTED:
53
+ return None
54
+ if checked is INVALID:
55
+ emit_diagnostic("before_send_invalid_event", "Python before_send returned an invalid event")
56
+ return event
57
+ return cast(dict[str, object], checked)
58
+
59
+
60
+ def _is_valid_event(event: object) -> bool:
61
+ result = _normalized(event)
62
+ return result is not INVALID and result is not EXHAUSTED
@@ -0,0 +1,202 @@
1
+ """Owned JSON and a closed interpreter for the vendored accepting Draft 7 schema.
2
+
3
+ Only native containers/scalars are inspected. No application serializers, remote
4
+ references or optional runtime dependencies participate in hook validation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import math
11
+ import re
12
+ from functools import lru_cache
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .evidence_wire import _timestamp, _uuid
17
+
18
+ MAX_OWNED_BYTES = 8 * 1024 * 1024
19
+ INVALID = object()
20
+ EXHAUSTED = object()
21
+
22
+
23
+ class ValidationBudgetExceeded(Exception):
24
+ """Withhold hook output rather than restore content it may have removed."""
25
+
26
+
27
+ def own_json(value: object) -> Any:
28
+ remaining = MAX_OWNED_BYTES
29
+ nodes = 4096
30
+
31
+ def copy(item: Any, depth: int) -> Any:
32
+ nonlocal remaining, nodes
33
+ nodes -= 1
34
+ if nodes < 0 or depth > 16 or remaining < 0:
35
+ raise ValidationBudgetExceeded
36
+ kind = type(item)
37
+ if item is None or kind is bool:
38
+ remaining -= 5
39
+ return item
40
+ if kind is str:
41
+ if len(item) > remaining:
42
+ raise ValidationBudgetExceeded
43
+ remaining -= len(item.encode("utf-8")) + 2
44
+ if remaining < 0:
45
+ raise ValidationBudgetExceeded
46
+ return item
47
+ if kind is int or kind is float:
48
+ # The accepting JavaScript number domain is finite IEEE-754.
49
+ if not -1.7976931348623157e308 <= item <= 1.7976931348623157e308 or not math.isfinite(item):
50
+ raise ValueError("non-finite number")
51
+ remaining -= 24
52
+ return item
53
+ if kind is list:
54
+ if len(item) > nodes:
55
+ raise ValidationBudgetExceeded
56
+ remaining -= 2 + len(item)
57
+ return [copy(child, depth + 1) for child in item]
58
+ if kind is dict:
59
+ if len(item) * 2 > nodes:
60
+ raise ValidationBudgetExceeded
61
+ remaining -= 2 + 2 * len(item)
62
+ result = {}
63
+ for key, child in item.items():
64
+ if type(key) is not str:
65
+ raise ValueError("non-string key")
66
+ result[copy(key, depth + 1)] = copy(child, depth + 1)
67
+ return result
68
+ raise ValueError("non-native JSON value")
69
+
70
+ try:
71
+ return copy(value, 0)
72
+ except ValidationBudgetExceeded:
73
+ return EXHAUSTED
74
+ except (ValueError, UnicodeError, OverflowError):
75
+ return INVALID
76
+
77
+
78
+ class CanonicalEventSchema:
79
+ KEYWORDS = frozenset(
80
+ "$schema $id $comment $ref type anyOf const enum properties required additionalProperties "
81
+ "items minimum maximum exclusiveMinimum minLength maxLength minItems maxItems format pattern".split()
82
+ )
83
+ TYPES = frozenset("null boolean number integer string array object".split())
84
+
85
+ def __init__(self, schema: dict[str, Any]) -> None:
86
+ self.root = schema
87
+ self.references: dict[str, Any] = {}
88
+ self.patterns: dict[str, re.Pattern[str]] = {}
89
+ self._compile(schema)
90
+
91
+ def _resolve(self, reference: str) -> Any:
92
+ if not reference.startswith("#/"):
93
+ raise ValueError("external schema ref")
94
+ if reference not in self.references:
95
+ node: Any = self.root
96
+ for part in reference[2:].split("/"):
97
+ key = part.replace("~1", "/").replace("~0", "~")
98
+ node = node[int(key)] if type(node) is list else node[key]
99
+ self.references[reference] = node
100
+ return self.references[reference]
101
+
102
+ def _compile(self, node: Any) -> None:
103
+ if type(node) is bool:
104
+ return
105
+ if not set(node).issubset(self.KEYWORDS):
106
+ raise ValueError("unsupported schema keyword")
107
+ kinds = node.get("type", [])
108
+ if not set([kinds] if type(kinds) is str else kinds).issubset(self.TYPES):
109
+ raise ValueError("unsupported schema type")
110
+ if node.get("format") not in (None, "uuid", "date-time"):
111
+ raise ValueError("unsupported schema format")
112
+ if "pattern" in node:
113
+ self.patterns[node["pattern"]] = re.compile(node["pattern"])
114
+ if "$ref" in node:
115
+ self._resolve(node["$ref"])
116
+ for child in node.get("properties", {}).values():
117
+ self._compile(child)
118
+ for child in node.get("anyOf", []):
119
+ self._compile(child)
120
+ for key in ("items", "additionalProperties"):
121
+ if key in node:
122
+ self._compile(node[key])
123
+
124
+ def accepts(self, value: Any) -> bool:
125
+ return self._check(self.root, value, [100_000], 0)
126
+
127
+ def _check(self, node: Any, value: Any, budget: list[int], depth: int) -> bool:
128
+ budget[0] -= 1
129
+ if depth > 64 or budget[0] < 0:
130
+ raise ValidationBudgetExceeded
131
+ if type(node) is bool:
132
+ return node
133
+ if "$ref" in node:
134
+ return self._check(self._resolve(node["$ref"]), value, budget, depth + 1)
135
+ if "anyOf" in node and not any(self._check(child, value, budget, depth + 1) for child in node["anyOf"]):
136
+ return False
137
+ if "type" in node:
138
+ kinds = node["type"] if type(node["type"]) is list else [node["type"]]
139
+ if not any(self._type(value, kind) for kind in kinds):
140
+ return False
141
+ if "const" in node and not self._equal(value, node["const"]):
142
+ return False
143
+ if "enum" in node and not any(self._equal(value, choice) for choice in node["enum"]):
144
+ return False
145
+ if type(value) is dict:
146
+ properties = node.get("properties", {})
147
+ return all(key in value for key in node.get("required", [])) and all(
148
+ self._check(properties.get(key, node.get("additionalProperties", True)), child, budget, depth + 1)
149
+ for key, child in value.items()
150
+ )
151
+ if type(value) is list:
152
+ return (
153
+ len(value) >= node.get("minItems", 0)
154
+ and len(value) <= node.get("maxItems", len(value))
155
+ and (
156
+ "items" not in node or all(self._check(node["items"], child, budget, depth + 1) for child in value)
157
+ )
158
+ )
159
+ if type(value) is str:
160
+ length = len(value.encode("utf-16-le")) // 2 # Zod counts JavaScript UTF-16 code units.
161
+ return (
162
+ length >= node.get("minLength", 0)
163
+ and length <= node.get("maxLength", length)
164
+ and ("pattern" not in node or bool(self.patterns[node["pattern"]].search(value)))
165
+ and (node.get("format") != "uuid" or _uuid(value))
166
+ and (node.get("format") != "date-time" or _timestamp(value))
167
+ )
168
+ if type(value) is int or type(value) is float:
169
+ return (
170
+ ("minimum" not in node or value >= node["minimum"])
171
+ and ("maximum" not in node or value <= node["maximum"])
172
+ and ("exclusiveMinimum" not in node or value > node["exclusiveMinimum"])
173
+ )
174
+ return True
175
+
176
+ @staticmethod
177
+ def _equal(value: Any, choice: Any) -> bool:
178
+ return (
179
+ type(value) is type(choice)
180
+ and value == choice
181
+ or (type(value) in (int, float) and type(choice) in (int, float) and value == choice)
182
+ )
183
+
184
+ @staticmethod
185
+ def _type(value: Any, kind: str) -> bool:
186
+ if kind == "number" or kind == "integer":
187
+ return type(value) in (int, float) and (kind == "number" or value == math.floor(value))
188
+ return type(value) is {"null": type(None), "boolean": bool, "string": str, "array": list, "object": dict}[kind]
189
+
190
+
191
+ @lru_cache(maxsize=1)
192
+ def _engine() -> CanonicalEventSchema:
193
+ return CanonicalEventSchema(json.loads(Path(__file__).with_name("event-envelope.schema.json").read_text()))
194
+
195
+
196
+ def valid_owned_event(value: dict[str, Any]) -> bool:
197
+ try:
198
+ return _engine().accepts(value)
199
+ except ValidationBudgetExceeded:
200
+ raise
201
+ except Exception:
202
+ return False
@@ -22,6 +22,7 @@ class CapturePolicy:
22
22
  capture_probe_events: str
23
23
  immediate_client_error_statuses: tuple[int, ...]
24
24
  immediate_client_error_path_rules: tuple[ImmediateClientErrorPathRule, ...] = ()
25
+ capture_diagnostic_context: bool = True
25
26
 
26
27
 
27
28
  @dataclass(frozen=True)
@@ -61,6 +62,7 @@ MINIMAL_CAPTURE_POLICY = CapturePolicy(
61
62
  capture_probe_events="buffer_only",
62
63
  immediate_client_error_statuses=(),
63
64
  immediate_client_error_path_rules=(),
65
+ capture_diagnostic_context=False,
64
66
  )
65
67
 
66
68
 
@@ -147,6 +149,9 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None:
147
149
  return None
148
150
  if immediate_client_error_statuses is None or immediate_client_error_path_rules is None:
149
151
  return None
152
+ diagnostic = payload.get("capture_diagnostic_context", True)
153
+ if type(diagnostic) is not bool:
154
+ return None
150
155
 
151
156
  return CapturePolicy(
152
157
  preset=preset,
@@ -156,6 +161,7 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None:
156
161
  capture_probe_events=capture_probe_events,
157
162
  immediate_client_error_statuses=immediate_client_error_statuses,
158
163
  immediate_client_error_path_rules=immediate_client_error_path_rules,
164
+ capture_diagnostic_context=preset != "minimal" and diagnostic,
159
165
  )
160
166
 
161
167