debugbundle-python 2.0.1__tar.gz → 2.1.1__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 (86) hide show
  1. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/PKG-INFO +117 -1
  2. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/README.md +116 -0
  3. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/pyproject.toml +4 -1
  4. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/__init__.py +16 -0
  5. debugbundle_python-2.1.1/src/debugbundle/before_send.py +62 -0
  6. debugbundle_python-2.1.1/src/debugbundle/canonical_event.py +202 -0
  7. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/capture_hooks.py +14 -6
  8. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/config.py +6 -0
  9. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/core.py +76 -318
  10. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/delivery_hooks.py +14 -0
  11. debugbundle_python-2.1.1/src/debugbundle/diagnostic_budget.py +191 -0
  12. debugbundle_python-2.1.1/src/debugbundle/event-envelope.schema.json +4904 -0
  13. debugbundle_python-2.1.1/src/debugbundle/evidence_delivery.py +115 -0
  14. debugbundle_python-2.1.1/src/debugbundle/evidence_http.py +45 -0
  15. debugbundle_python-2.1.1/src/debugbundle/evidence_json.py +68 -0
  16. debugbundle_python-2.1.1/src/debugbundle/evidence_wire.py +409 -0
  17. debugbundle_python-2.1.1/src/debugbundle/exception_capture.py +98 -0
  18. debugbundle_python-2.1.1/src/debugbundle/exception_evidence.py +199 -0
  19. debugbundle_python-2.1.1/src/debugbundle/exception_identity.py +47 -0
  20. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/exception_snapshot.py +1 -1
  21. debugbundle_python-2.1.1/src/debugbundle/flush_delivery.py +203 -0
  22. debugbundle_python-2.1.1/src/debugbundle/ingestion_wire.py +75 -0
  23. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/integrations/relay_django.py +4 -1
  24. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/integrations/relay_fastapi.py +10 -2
  25. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/integrations/relay_flask.py +4 -1
  26. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/logger_integrations.py +16 -0
  27. debugbundle_python-2.1.1/src/debugbundle/operation_context.py +121 -0
  28. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/process_support.py +7 -0
  29. debugbundle_python-2.1.1/src/debugbundle/queue_admission.py +79 -0
  30. debugbundle_python-2.1.1/src/debugbundle/recovery_capture.py +43 -0
  31. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/relay.py +93 -36
  32. debugbundle_python-2.1.1/src/debugbundle/relay_delivery.py +229 -0
  33. debugbundle_python-2.1.1/src/debugbundle/remote_config_client.py +81 -0
  34. debugbundle_python-2.1.1/src/debugbundle/scope_ownership.py +99 -0
  35. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/structlog_integration.py +21 -0
  36. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/transport.py +20 -14
  37. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle_python.egg-info/PKG-INFO +117 -1
  38. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle_python.egg-info/SOURCES.txt +21 -0
  39. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_before_send.py +96 -5
  40. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_contracts.py +3 -0
  41. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_delivery_hooks.py +26 -5
  42. debugbundle_python-2.1.1/tests/test_evidence_capture.py +272 -0
  43. debugbundle_python-2.1.1/tests/test_evidence_wire.py +198 -0
  44. debugbundle_python-2.1.1/tests/test_exception_evidence.py +125 -0
  45. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_exception_snapshot.py +16 -0
  46. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_framework_integrations.py +48 -9
  47. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_http_integration.py +2 -4
  48. debugbundle_python-2.1.1/tests/test_logging_exception.py +110 -0
  49. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_relay.py +9 -1
  50. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_relay_delivery.py +16 -1
  51. debugbundle_python-2.1.1/tests/test_relay_evidence.py +215 -0
  52. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_remote_config.py +16 -7
  53. debugbundle_python-2.1.1/tests/test_transport_reliability.py +286 -0
  54. debugbundle_python-2.0.1/src/debugbundle/before_send.py +0 -218
  55. debugbundle_python-2.0.1/src/debugbundle/queue_admission.py +0 -39
  56. debugbundle_python-2.0.1/src/debugbundle/relay_delivery.py +0 -138
  57. debugbundle_python-2.0.1/tests/test_transport_reliability.py +0 -144
  58. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/LICENSE +0 -0
  59. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/setup.cfg +0 -0
  60. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/acknowledgement.py +0 -0
  61. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/event_support.py +0 -0
  62. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/flush_aggregates.py +0 -0
  63. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/integrations/__init__.py +0 -0
  64. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/integrations/common.py +0 -0
  65. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/integrations/django.py +0 -0
  66. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/integrations/fastapi.py +0 -0
  67. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/integrations/flask.py +0 -0
  68. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/probe_capture.py +0 -0
  69. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/py.typed +0 -0
  70. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/redaction.py +0 -0
  71. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/request_capture_policy.py +0 -0
  72. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/suppression.py +0 -0
  73. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle/trigger_token.py +0 -0
  74. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle_python.egg-info/dependency_links.txt +0 -0
  75. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle_python.egg-info/requires.txt +0 -0
  76. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/src/debugbundle_python.egg-info/top_level.txt +0 -0
  77. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_hooks.py +0 -0
  78. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_optional_imports.py +0 -0
  79. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_privacy_conformance.py +0 -0
  80. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_repository_metadata.py +0 -0
  81. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_schema_validation.py +0 -0
  82. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_sdk.py +0 -0
  83. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_smoke_script.py +0 -0
  84. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_structlog_filtering.py +0 -0
  85. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/tests/test_suppression.py +0 -0
  86. {debugbundle_python-2.0.1 → debugbundle_python-2.1.1}/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.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).
@@ -1,7 +1,17 @@
1
1
  # debugbundle-python
2
2
 
3
+ 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.
4
+
3
5
  Python SDK for DebugBundle.
4
6
 
7
+ After `make install-docker`, `make evidence-performance` compares identical
8
+ exception/recovery calls with evidence disabled and enabled, including a full
9
+ warning queue, with networking disabled. It reports capture timing separately from
10
+ Python `tracemalloc` peak/live allocation measurements, verifies native causes and
11
+ original-error priority, and checks existing retention ceilings. Setup and cleanup
12
+ are excluded; traced allocations are Python-managed memory, not cumulative churn
13
+ or total process RSS.
14
+
5
15
  ![PyPI](https://img.shields.io/pypi/v/debugbundle-python?label=pypi)
6
16
  ![CI](https://img.shields.io/github/actions/workflow/status/debugbundle/debugbundle-python/ci.yml?branch=main&label=ci)
7
17
  ![License](https://img.shields.io/badge/license-Apache--2.0-blue)
@@ -10,6 +20,93 @@ Use this package to capture Python backend exceptions, request metadata, structu
10
20
 
11
21
  Requires Python 3.10 or newer.
12
22
 
23
+ ## Evidence in 2.1.0
24
+
25
+ Version 2.1.0 adds `capture_exception_evidence=True` and
26
+ `capture_recovery_observations=False` installation options. Collection requires
27
+ verified delivery/reader support, discovered on existing config/ACK traffic with
28
+ `debugbundle_evidence=1`. No capture call performs discovery or adds polling.
29
+ `evidence_capabilities` is an optional descriptor pin asserting the **entire**
30
+ path, including local readers, relays and wrappers; later discovery can revoke it.
31
+ Without proof, exception events retain their legacy shape. These additions require upgrading from 2.0.1 to 2.1.0.
32
+
33
+ Native cause, implicit context and Python 3.11+ exception-group evidence excludes
34
+ locals, source excerpts, arbitrary renderers and application properties. Python
35
+ 3.10 has no native exception groups. Graphs retain at most eight nodes, depth
36
+ four, five children and 32 frames per node; all optional exception additions fit
37
+ 32 KiB. Uncaught native capture bypasses `before_send` and uses two nodes, eight
38
+ frames and a 4-KiB total including execution/reference metadata. The original
39
+ sanitised stack remains separate.
40
+
41
+ `with sdk.operation_context({"kind": "job", "name": "invoice.calculate",
42
+ "limit_ms": 5000}):` supplies a fresh operation scope and monotonic elapsed time
43
+ to synchronous or asynchronous work inside the block. Kinds are `request`,
44
+ `command`, `job` and `unknown`. The limit is a known configured limit, not time
45
+ remaining. Framework requests use their existing begin/end lifecycle. Completed
46
+ owners revoke copied async context; reinitialisation/fork discard prior ownership.
47
+ Application results and exceptions pass through the context manager unchanged.
48
+
49
+ The existing background sender remains best-effort at immediate process exit.
50
+ The uncaught hook collects bounded native evidence without waiting for transport;
51
+ applications needing a delivery attempt use explicit `flush()` in a controlled
52
+ shutdown window. This release does not add default crash-file persistence or
53
+ synchronous capture transport.
54
+
55
+ Pass a fresh UUID v4 `failure_ref` in exception/log/request context. A valid
56
+ `evidence_scope` can specify a fresh page or operation UUID; an active operation
57
+ provides the default. `capture_recovery({"failure_ref": failure_ref,
58
+ "attempt_id": attempt_id, "state": "attempt_completed"})` records an explicit
59
+ application observation. Optional fields are `evidence_scope`, technical
60
+ `operation`, `status_code`, `duration_ms` and primitive `correlation` metadata.
61
+ Page scopes require a nonblank session ID, including the valid string `"0"`.
62
+ Only explicit `recovery_observed` claims semantic recovery; HTTP 200 and
63
+ `attempt_completed` leave it unknown. The other states are `attempt_started`,
64
+ `attempt_failed` and `failure_persisted`.
65
+
66
+ Standalone recovery requires its local opt-in, `recovery_v1` support and server
67
+ `capture_diagnostic_context`; minimal policy disables it. Privacy, sampling,
68
+ resulting hook-event policy and queue limits still apply. Fifty failure windows,
69
+ five attempts and twenty observations per failure share 64 KiB of tracker and
70
+ pending-payload ownership, retained until ACK/drop even offline. Canonical hooks
71
+ may change any allowed identity/type field; ownership follows the replacement.
72
+ The complete closed envelope is checked against the vendored accepting Draft 7
73
+ schema, followed by evidence semantics and mandatory privacy. Only native JSON
74
+ containers/scalars are copied; custom serializers and container subclasses are
75
+ never invoked by hook validation. Invalid or throwing hooks keep the protected
76
+ original. Exhausted validation or an unfit valid replacement is withheld, so an
77
+ application redaction cannot be undone by a budget fallback.
78
+
79
+ Native HTTP requests select an ordered prefix by the actual compact UTF-8 JSON
80
+ serializer and remain within the receiving 256-KiB limit. Unsent finalized tails
81
+ stay charged and continue through the existing sender without repeating their
82
+ hooks. Optional SDK evidence is reduced before withholding an unfit primary;
83
+ explicit custom/local transports retain their established budgets. Browser
84
+ relays measure after server identity/token expansion, split downstream requests
85
+ and keep incomplete spools. A legacy custom success remains accepted but cannot
86
+ mark a durable spool delivered; that requires complete canonical acknowledgements.
87
+ Relay chunks share a five-second monotonic forwarding deadline, and spool markers
88
+ are created exclusively with private permissions without following symlinks.
89
+
90
+ Native HTTP ACK reads stop at 256 KiB and close the response. Requests ask for
91
+ identity encoding; unexpected compressed ACKs are withheld. The five-second
92
+ deadline is checked between streamed reads; HTTPX's synchronous per-read/connect/
93
+ write timeouts remain advisory and an in-progress native read can overrun that
94
+ deadline. Explicit custom transports/config fetchers retain their own timeout,
95
+ allocation and cancellation responsibilities. No additional timer thread or
96
+ inline capture transport is introduced.
97
+
98
+ Browser relays and Django/Flask/FastAPI relay factories accept the same optional
99
+ `evidence_capabilities` pin. They preserve closed DTOs, protected application
100
+ context and server-owned credentials, and advertise only verified downstream
101
+ support. Opted-in replies keep string `errors` and add indexed `evidence_errors`;
102
+ partial batches return 202, while legacy partial responses retain 400. A durable
103
+ spool accepts locally but is marked delivered only after a valid complete ACK.
104
+ Malformed ACKs cannot grant proof. Only negotiated `invalid_event` rejection of
105
+ an original exception can retry its stripped legacy form once; standalone
106
+ context, policy and authentication rejections do not use this fallback.
107
+ Framework relay adapters read at most the configured body limit plus one byte
108
+ before parsing, and preserve the negotiation query/header through the handler.
109
+
13
110
  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
111
 
15
112
  ## Automatic capture and application filtering
@@ -322,3 +419,22 @@ Apache-2.0. See `LICENSE`.
322
419
  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`.
323
420
 
324
421
  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.
422
+
423
+ ## Probe checkpoint guidance
424
+
425
+ Application instrumentation supplies probe labels and bounded data. Deploy a new
426
+ checkpoint before activating it. Remote activation affects future matching calls
427
+ when policy permits; it does not inject code or recover unretained measurements.
428
+ Built-in runtime/error/device collectors keep their documented SDK settings.
429
+
430
+ Use fixed labels and allowlisted technical states: `media.preview.checkpoint`
431
+ (stage/input kind/attempt), `auth.session.checkpoint` (observed session presence
432
+ and result), `job.dependency.checkpoint` (named dependency/attempt/result), or
433
+ `cache.preview.checkpoint` (hit/miss/coarse age bucket). Omit customer identifiers,
434
+ tokens/cookies, cache keys, URLs and raw payloads. HTTP 200 or attempt completion
435
+ alone does not establish semantic recovery.
436
+
437
+ Empty probe data means no matching retained observation for the selected sample
438
+ window. It does not confirm activation delivery to every SDK or execution of a
439
+ label. Check the deployed SDK/wrapper version, instrumentation, scope, expiry,
440
+ 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.1"
7
+ version = "2.1.1"
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
  ]
@@ -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
@@ -30,14 +30,19 @@ class DebugBundleLogHandler(logging.Handler):
30
30
  def emit(self, record: logging.LogRecord) -> None:
31
31
  if not self._sdk._log_level_eligible(record.levelname.lower()):
32
32
  return
33
+ context = {"logger_name": record.name, "pathname": record.pathname, "lineno": record.lineno}
34
+ if (
35
+ record.levelno >= logging.ERROR
36
+ and type(record.exc_info) is tuple
37
+ and len(record.exc_info) == 3
38
+ and isinstance(record.exc_info[1], BaseException)
39
+ ):
40
+ self._sdk.capture_exception(record.exc_info[1], {**context, "log_message": safe_log_message(record)})
41
+ return
33
42
  self._sdk.capture_log(
34
43
  safe_log_message(record),
35
44
  level=record.levelname.lower(),
36
- context={
37
- "logger_name": record.name,
38
- "pathname": record.pathname,
39
- "lineno": record.lineno,
40
- },
45
+ context=context,
41
46
  )
42
47
 
43
48
 
@@ -55,7 +60,10 @@ def safe_log_message(record: logging.LogRecord) -> str:
55
60
  if type(args) is not tuple or len(args) > 8:
56
61
  return template + " [log arguments withheld]"
57
62
  safe_args = [
58
- value[:1024] if type(value) is str else str(value) if type(value) in (int, float, bool)
63
+ value[:1024]
64
+ if type(value) is str
65
+ else str(value)
66
+ if type(value) in (int, float, bool)
59
67
  else "[unsupported argument]"
60
68
  for value in args
61
69
  ]