debugbundle-python 2.0.1__py3-none-any.whl → 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. debugbundle/__init__.py +16 -0
  2. debugbundle/before_send.py +35 -191
  3. debugbundle/canonical_event.py +202 -0
  4. debugbundle/config.py +6 -0
  5. debugbundle/core.py +76 -318
  6. debugbundle/delivery_hooks.py +14 -0
  7. debugbundle/diagnostic_budget.py +191 -0
  8. debugbundle/event-envelope.schema.json +4904 -0
  9. debugbundle/evidence_delivery.py +115 -0
  10. debugbundle/evidence_http.py +45 -0
  11. debugbundle/evidence_json.py +68 -0
  12. debugbundle/evidence_wire.py +409 -0
  13. debugbundle/exception_capture.py +92 -0
  14. debugbundle/exception_evidence.py +199 -0
  15. debugbundle/exception_snapshot.py +1 -1
  16. debugbundle/flush_delivery.py +203 -0
  17. debugbundle/ingestion_wire.py +75 -0
  18. debugbundle/integrations/relay_django.py +4 -1
  19. debugbundle/integrations/relay_fastapi.py +10 -2
  20. debugbundle/integrations/relay_flask.py +4 -1
  21. debugbundle/operation_context.py +121 -0
  22. debugbundle/process_support.py +7 -0
  23. debugbundle/queue_admission.py +42 -2
  24. debugbundle/recovery_capture.py +43 -0
  25. debugbundle/relay.py +93 -36
  26. debugbundle/relay_delivery.py +108 -17
  27. debugbundle/remote_config_client.py +81 -0
  28. debugbundle/scope_ownership.py +99 -0
  29. debugbundle/transport.py +20 -14
  30. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/METADATA +116 -1
  31. debugbundle_python-2.1.0.dist-info/RECORD +51 -0
  32. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/WHEEL +1 -1
  33. debugbundle_python-2.0.1.dist-info/RECORD +0 -36
  34. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
  35. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/top_level.txt +0 -0
debugbundle/__init__.py CHANGED
@@ -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
  ]
@@ -1,75 +1,37 @@
1
+ """Canonical, owned hook results with explicit invalid and exhausted outcomes."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  import copy
4
- import uuid
5
- from collections.abc import Callable, Mapping
6
- from datetime import datetime
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
7
11
 
8
12
  BeforeSendHook = Callable[[dict[str, object]], dict[str, object] | None]
13
+ _MOBILE = ("@debugbundle/sdk-android", "@debugbundle/sdk-swift", "@debugbundle/sdk-react-native")
9
14
 
10
- _EVENT_PAYLOAD_FIELDS: dict[str, tuple[str, ...]] = {
11
- "backend_exception": ("name", "message", "stack", "handled", "request", "response", "runtime"),
12
- "request_event": ("method", "path", "query", "headers", "response_status", "duration_ms"),
13
- "log_event": ("level", "message", "attributes"),
14
- "frontend_breadcrumb": ("breadcrumb_type", "data"),
15
- "frontend_exception": ("name", "message", "stack"),
16
- "deploy_metadata": ("commit_sha", "version", "branch", "environment", "deployed_at"),
17
- "error_suppressed": (
18
- "fingerprint",
19
- "suppressed_count",
20
- "window_seconds",
21
- "first_seen",
22
- "last_seen",
23
- ),
24
- "probe_event": ("label", "data", "activation_id", "probe_label_pattern"),
25
- }
26
- _EVENT_PAYLOAD_ALLOWED_FIELDS: dict[str, frozenset[str]] = {
27
- "backend_exception": frozenset((*_EVENT_PAYLOAD_FIELDS["backend_exception"], "probe_data")),
28
- "request_event": frozenset(
29
- (
30
- *_EVENT_PAYLOAD_FIELDS["request_event"],
31
- "body",
32
- "route_template",
33
- "response_headers",
34
- "response_body",
35
- "device",
36
- )
37
- ),
38
- "log_event": frozenset((*_EVENT_PAYLOAD_FIELDS["log_event"], "device")),
39
- "frontend_breadcrumb": frozenset((*_EVENT_PAYLOAD_FIELDS["frontend_breadcrumb"], "route", "device")),
40
- "frontend_exception": frozenset(
41
- (
42
- *_EVENT_PAYLOAD_FIELDS["frontend_exception"],
43
- "route",
44
- "browser",
45
- "breadcrumbs",
46
- "device",
47
- "browser_event",
48
- "rejection_reason",
49
- "dom_context",
50
- "probe_data",
51
- )
52
- ),
53
- "deploy_metadata": frozenset(_EVENT_PAYLOAD_FIELDS["deploy_metadata"]),
54
- "error_suppressed": frozenset((*_EVENT_PAYLOAD_FIELDS["error_suppressed"], "device")),
55
- "probe_event": frozenset((*_EVENT_PAYLOAD_FIELDS["probe_event"], "device")),
56
- }
57
- _ROOT_FIELDS = frozenset(
58
- (
59
- "schema_version",
60
- "event_id",
61
- "event_type",
62
- "project_token",
63
- "project_id",
64
- "sdk_name",
65
- "sdk_version",
66
- "service",
67
- "occurred_at",
68
- "correlation",
69
- "context",
70
- "payload",
71
- )
72
- )
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
73
35
 
74
36
 
75
37
  def apply_before_send(
@@ -79,140 +41,22 @@ def apply_before_send(
79
41
  ) -> dict[str, object] | None:
80
42
  if hook is None:
81
43
  return event
82
-
83
44
  try:
84
45
  result = hook(copy.deepcopy(event))
85
46
  except Exception:
86
47
  emit_diagnostic("before_send_failed", "Python before_send hook failed")
87
48
  return event
88
-
89
49
  if result is None:
90
50
  return None
91
- if not _is_valid_event(result):
51
+ checked = _normalized(result)
52
+ if checked is EXHAUSTED:
53
+ return None
54
+ if checked is INVALID:
92
55
  emit_diagnostic("before_send_invalid_event", "Python before_send returned an invalid event")
93
56
  return event
94
- return result
57
+ return cast(dict[str, object], checked)
95
58
 
96
59
 
97
60
  def _is_valid_event(event: object) -> bool:
98
- if not isinstance(event, dict):
99
- return False
100
- if not set(event).issubset(_ROOT_FIELDS):
101
- return False
102
- event_type = event.get("event_type")
103
- if not isinstance(event_type, str) or event_type not in _EVENT_PAYLOAD_FIELDS:
104
- return False
105
- if not all(
106
- isinstance(event.get(field), str) and event[field]
107
- for field in ("schema_version", "sdk_name", "sdk_version", "occurred_at")
108
- ):
109
- return False
110
- if not _timestamp(event["occurred_at"]):
111
- return False
112
- try:
113
- uuid.UUID(str(event.get("event_id")))
114
- except (ValueError, TypeError, AttributeError):
115
- return False
116
- service = event.get("service")
117
- if (
118
- not isinstance(service, dict)
119
- or not isinstance(service.get("name"), str)
120
- or not service["name"]
121
- or not isinstance(service.get("environment"), str)
122
- or not service["environment"]
123
- ):
124
- return False
125
- payload = event.get("payload")
126
- if not isinstance(payload, dict):
127
- return False
128
- if not set(payload).issubset(_EVENT_PAYLOAD_ALLOWED_FIELDS[event_type]):
129
- return False
130
- if not all(field in payload for field in _EVENT_PAYLOAD_FIELDS[event_type]):
131
- return False
132
- return _has_valid_payload_shape(event_type, payload)
133
-
134
-
135
- def _has_valid_payload_shape(event_type: str, payload: Mapping[object, object]) -> bool:
136
- if event_type == "backend_exception":
137
- return (
138
- _has_nonempty_strings(payload, "name", "message", "stack")
139
- and isinstance(payload["handled"], bool)
140
- and all(isinstance(payload[field], dict) for field in ("request", "response", "runtime"))
141
- and _optional_dict(payload, "probe_data")
142
- )
143
- if event_type == "request_event":
144
- return (
145
- _has_nonempty_strings(payload, "method", "path")
146
- and all(isinstance(payload[field], dict) for field in ("query", "headers"))
147
- and _nonnegative_number(payload["response_status"])
148
- and _nonnegative_number(payload["duration_ms"])
149
- and _optional_dict(payload, "response_headers")
150
- )
151
- if event_type == "log_event":
152
- return _has_nonempty_strings(payload, "level", "message") and isinstance(payload["attributes"], dict)
153
- if event_type == "frontend_breadcrumb":
154
- return _has_nonempty_strings(payload, "breadcrumb_type") and isinstance(payload["data"], dict)
155
- if event_type == "frontend_exception":
156
- return (
157
- _has_nonempty_strings(payload, "name", "message", "stack")
158
- and (payload.get("breadcrumbs") is None or isinstance(payload["breadcrumbs"], list))
159
- and _optional_dict(payload, "probe_data")
160
- )
161
- if event_type == "deploy_metadata":
162
- return _has_nonempty_strings(payload, "commit_sha", "version", "branch", "environment") and _timestamp(
163
- payload["deployed_at"]
164
- )
165
- if event_type == "error_suppressed":
166
- return (
167
- _has_nonempty_strings(payload, "fingerprint")
168
- and _nonnegative_integer(payload["suppressed_count"])
169
- and _positive_integer(payload["window_seconds"])
170
- and _timestamp(payload["first_seen"])
171
- and _timestamp(payload["last_seen"])
172
- )
173
- if event_type == "probe_event":
174
- activation_id = payload["activation_id"]
175
- return (
176
- _has_nonempty_strings(payload, "label", "probe_label_pattern")
177
- and isinstance(payload["data"], dict)
178
- and (activation_id is None or _uuid(activation_id))
179
- )
180
- return False
181
-
182
-
183
- def _has_nonempty_strings(payload: Mapping[object, object], *fields: str) -> bool:
184
- return all(isinstance(payload.get(field), str) and bool(str(payload[field]).strip()) for field in fields)
185
-
186
-
187
- def _optional_dict(payload: Mapping[object, object], field: str) -> bool:
188
- return field not in payload or isinstance(payload[field], dict)
189
-
190
-
191
- def _nonnegative_number(value: object) -> bool:
192
- return isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0
193
-
194
-
195
- def _nonnegative_integer(value: object) -> bool:
196
- return isinstance(value, int) and not isinstance(value, bool) and value >= 0
197
-
198
-
199
- def _positive_integer(value: object) -> bool:
200
- return isinstance(value, int) and not isinstance(value, bool) and value > 0
201
-
202
-
203
- def _uuid(value: object) -> bool:
204
- try:
205
- uuid.UUID(str(value))
206
- except (ValueError, TypeError, AttributeError):
207
- return False
208
- return isinstance(value, str)
209
-
210
-
211
- def _timestamp(value: object) -> bool:
212
- if not isinstance(value, str):
213
- return False
214
- try:
215
- datetime.fromisoformat(value.replace("Z", "+00:00"))
216
- except ValueError:
217
- return False
218
- return True
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
debugbundle/config.py CHANGED
@@ -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