debugbundle-python 2.0.0__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 (37) hide show
  1. debugbundle/__init__.py +16 -0
  2. debugbundle/acknowledgement.py +3 -1
  3. debugbundle/before_send.py +35 -191
  4. debugbundle/canonical_event.py +202 -0
  5. debugbundle/config.py +6 -0
  6. debugbundle/core.py +76 -316
  7. debugbundle/delivery_hooks.py +14 -0
  8. debugbundle/diagnostic_budget.py +191 -0
  9. debugbundle/event-envelope.schema.json +4904 -0
  10. debugbundle/event_support.py +1 -1
  11. debugbundle/evidence_delivery.py +115 -0
  12. debugbundle/evidence_http.py +45 -0
  13. debugbundle/evidence_json.py +68 -0
  14. debugbundle/evidence_wire.py +409 -0
  15. debugbundle/exception_capture.py +92 -0
  16. debugbundle/exception_evidence.py +199 -0
  17. debugbundle/exception_snapshot.py +1 -1
  18. debugbundle/flush_delivery.py +203 -0
  19. debugbundle/ingestion_wire.py +75 -0
  20. debugbundle/integrations/relay_django.py +4 -1
  21. debugbundle/integrations/relay_fastapi.py +10 -2
  22. debugbundle/integrations/relay_flask.py +4 -1
  23. debugbundle/operation_context.py +121 -0
  24. debugbundle/process_support.py +7 -0
  25. debugbundle/queue_admission.py +42 -2
  26. debugbundle/recovery_capture.py +43 -0
  27. debugbundle/relay.py +93 -36
  28. debugbundle/relay_delivery.py +108 -17
  29. debugbundle/remote_config_client.py +81 -0
  30. debugbundle/scope_ownership.py +99 -0
  31. debugbundle/transport.py +49 -20
  32. {debugbundle_python-2.0.0.dist-info → debugbundle_python-2.1.0.dist-info}/METADATA +122 -1
  33. debugbundle_python-2.1.0.dist-info/RECORD +51 -0
  34. {debugbundle_python-2.0.0.dist-info → debugbundle_python-2.1.0.dist-info}/WHEEL +1 -1
  35. debugbundle_python-2.0.0.dist-info/RECORD +0 -36
  36. {debugbundle_python-2.0.0.dist-info → debugbundle_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
  37. {debugbundle_python-2.0.0.dist-info → debugbundle_python-2.1.0.dist-info}/top_level.txt +0 -0
@@ -264,7 +264,7 @@ def sdk_version() -> str:
264
264
  try:
265
265
  return metadata.version("debugbundle-python")
266
266
  except metadata.PackageNotFoundError:
267
- return "2.0.0"
267
+ return "2.0.1"
268
268
 
269
269
 
270
270
  def sdk_config_endpoint(events_endpoint: str) -> str:
@@ -0,0 +1,115 @@
1
+ """Per-destination proof and bounded optional evidence assembly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from .evidence_wire import capture_reference, parse_capabilities, reference, strip_unsupported
9
+
10
+ if TYPE_CHECKING:
11
+ from .core import DebugBundleSdk
12
+
13
+
14
+ class EvidenceDelivery:
15
+ def __init__(self, pin: object = None) -> None:
16
+ self.features: list[str] = parse_capabilities(pin)["features"]
17
+
18
+ def supports(self, feature: str) -> bool:
19
+ return feature in self.features
20
+
21
+ def observe(self, body: object, required: bool = False, valid: bool = True) -> bool:
22
+ if not required and (type(body) is not dict or "evidence_capabilities" not in body):
23
+ return False
24
+ descriptor = body.get("evidence_capabilities") if type(body) is dict else None
25
+ previous = self.features
26
+ self.features = parse_capabilities(descriptor if valid else None)["features"]
27
+ return any(feature not in self.features for feature in previous)
28
+
29
+
30
+ def add_reference(sdk: DebugBundleSdk, merged: dict[str, object]) -> dict[str, object]:
31
+ if not (sdk._evidence.supports("recovery_v1") or sdk._evidence.supports("resource_observations_v1")):
32
+ return merged
33
+ operation = sdk._operations.current()
34
+ selected = capture_reference(merged, operation.scope if operation is not None else None)
35
+ if selected is None:
36
+ return merged
37
+ return {
38
+ **{key: value for key, value in merged.items() if key not in ("failure_ref", "evidence_scope")},
39
+ "evidence_reference": selected,
40
+ }
41
+
42
+
43
+ def filter_evidence(sdk: DebugBundleSdk, event: dict[str, Any]) -> dict[str, Any] | None:
44
+ features = sdk._evidence.features
45
+ if not sdk._capture_exception_evidence:
46
+ features = [feature for feature in features if feature != "exception_evidence_v1"]
47
+ return strip_unsupported(event, features)
48
+
49
+
50
+ def allows_recovery(sdk: DebugBundleSdk) -> bool:
51
+ return (
52
+ sdk._capture_recovery_observations
53
+ and sdk._evidence.supports("recovery_v1")
54
+ and sdk._capture_policy.preset != "minimal"
55
+ and sdk._capture_policy.capture_diagnostic_context
56
+ )
57
+
58
+
59
+ def reconcile_diagnostic(
60
+ sdk: DebugBundleSdk, original: dict[str, Any] | None, replacement: dict[str, Any] | None
61
+ ) -> bool:
62
+ original_id = (
63
+ original["event_id"] if original is not None and original["event_type"] == "diagnostic_context" else None
64
+ )
65
+ if replacement is None:
66
+ if original_id is not None:
67
+ sdk._diagnostics.release([original_id])
68
+ return False
69
+ now = sdk._time_provider() * 1000
70
+ if replacement["event_type"] == "diagnostic_context":
71
+ payload = replacement["payload"]
72
+ if payload["kind"] != "recovery" or not allows_recovery(sdk):
73
+ if original_id is not None:
74
+ sdk._diagnostics.release([original_id])
75
+ return False
76
+ size = sdk._event_bytes(replacement)
77
+ return (
78
+ sdk._diagnostics.replace(original_id, replacement["event_id"], payload, size, now)
79
+ if original_id is not None
80
+ else sdk._diagnostics.reserve(replacement["event_id"], payload, size, now)
81
+ )
82
+ if original_id is not None:
83
+ sdk._diagnostics.release([original_id])
84
+ context = replacement.get("context")
85
+ selected = reference(context.get("evidence_reference")) if type(context) is dict else None
86
+ if selected is not None and original is not None:
87
+ # Admission precedes the delivery hook. Register only its protected
88
+ # replacement, so a canonical ID change cannot invent a second failure.
89
+ try:
90
+ occurred = datetime.fromisoformat(replacement["occurred_at"].replace("Z", "+00:00")).timestamp() * 1000
91
+ sdk._diagnostics.register_failure(selected, replacement["event_id"], occurred)
92
+ except (ValueError, TypeError, OverflowError):
93
+ pass
94
+ return True
95
+
96
+
97
+ def downgrade_rejections(
98
+ sdk: DebugBundleSdk, batch: list[dict[str, object]], errors: tuple[tuple[int, str], ...], lost: bool
99
+ ) -> list[dict[str, object]]:
100
+ retries: list[dict[str, object]] = []
101
+ if not lost:
102
+ return retries
103
+ for index, reason in errors:
104
+ original = batch[index]
105
+ if reason != "invalid_event" or original["event_type"] not in ("backend_exception", "frontend_exception"):
106
+ continue
107
+ reduced = filter_evidence(sdk, original)
108
+ if reduced is None or reduced == original:
109
+ continue
110
+ size = sdk._event_bytes(reduced)
111
+ sdk._buffer_bytes += size - sdk._buffer_sizes.pop(id(original))
112
+ sdk._buffer_sizes[id(reduced)] = size
113
+ sdk._hook_bypass.add(id(reduced))
114
+ retries.append(reduced)
115
+ return retries
@@ -0,0 +1,45 @@
1
+ """Existing-request negotiation and bounded native acknowledgement reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
8
+
9
+ import httpx
10
+
11
+ MAX_RESPONSE_BYTES = 256 * 1024
12
+
13
+
14
+ def evidence_url(url: str) -> str:
15
+ parsed = urlsplit(url)
16
+ query = [
17
+ (key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key != "debugbundle_evidence"
18
+ ]
19
+ query.append(("debugbundle_evidence", "1"))
20
+ return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment))
21
+
22
+
23
+ def bounded_response_json(response: httpx.Response, deadline: float) -> object | None:
24
+ try:
25
+ length = response.headers.get("content-length")
26
+ if length is not None and length.isdigit() and int(length) > MAX_RESPONSE_BYTES:
27
+ return None
28
+ # Native ACK requests ask for identity. Do not allocate an unbounded
29
+ # decompressed body if an intermediary ignores that request.
30
+ if response.headers.get("content-encoding", "identity").lower() not in ("identity", ""):
31
+ return None
32
+ if response.is_stream_consumed:
33
+ data = response.content
34
+ if len(data) > MAX_RESPONSE_BYTES or time.monotonic() > deadline:
35
+ return None
36
+ else:
37
+ buffer = bytearray()
38
+ for chunk in response.iter_raw():
39
+ if len(chunk) > MAX_RESPONSE_BYTES - len(buffer) or time.monotonic() > deadline:
40
+ return None
41
+ buffer.extend(chunk)
42
+ data = buffer
43
+ return json.loads(data) if time.monotonic() <= deadline else None
44
+ except (ValueError, TypeError, RecursionError, OverflowError):
45
+ return None
@@ -0,0 +1,68 @@
1
+ """Byte accounting for validated evidence, using the accepting JSON number form."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from typing import Any
8
+
9
+ _OVER = 32769
10
+
11
+
12
+ def _number_size(value: int | float) -> int:
13
+ if type(value) is int and abs(value) <= 9007199254740991:
14
+ return len(str(value))
15
+ number = float(value)
16
+ if not math.isfinite(number):
17
+ return _OVER
18
+ if number == 0:
19
+ return 1
20
+ negative = number < 0
21
+ mantissa, _, exponent = repr(abs(number)).lower().partition("e")
22
+ before = len(mantissa.partition(".")[0])
23
+ raw = mantissa.replace(".", "")
24
+ leading = len(raw) - len(raw.lstrip("0"))
25
+ digits = raw.lstrip("0").rstrip("0")
26
+ position = before + int(exponent or "0") - leading
27
+ power = position - 1
28
+ # ECMAScript uses decimal notation for [1e-6, 1e21), independent
29
+ # of the producer language's exponent padding and float formatting.
30
+ if -6 <= power <= 20:
31
+ length = 2 - position + len(digits) if position <= 0 else max(position, len(digits))
32
+ if 0 < position < len(digits):
33
+ length += 1
34
+ else:
35
+ length = len(digits) + (1 if len(digits) > 1 else 0) + 2 + len(str(abs(power)))
36
+ return length + int(negative)
37
+
38
+
39
+ def canonical_size(value: Any) -> int:
40
+ remaining = 4096
41
+
42
+ def count(item: Any, depth: int) -> int:
43
+ nonlocal remaining
44
+ remaining -= 1
45
+ if depth > 16 or remaining < 0:
46
+ return _OVER
47
+ if item is None:
48
+ return 4
49
+ if type(item) is bool:
50
+ return 4 if item else 5
51
+ if type(item) is str:
52
+ return len(json.dumps(item, ensure_ascii=False).encode("utf-8"))
53
+ if type(item) is int or type(item) is float:
54
+ return _number_size(item)
55
+ if type(item) is list and len(item) <= 256:
56
+ return 2 + max(0, len(item) - 1) + sum(count(child, depth + 1) for child in item)
57
+ if type(item) is dict and len(item) <= 256 and all(type(key) is str for key in item):
58
+ return (
59
+ 2
60
+ + max(0, len(item) - 1)
61
+ + sum(count(key, depth + 1) + 1 + count(child, depth + 1) for key, child in item.items())
62
+ )
63
+ return _OVER
64
+
65
+ try:
66
+ return count(value, 0)
67
+ except (ValueError, OverflowError, UnicodeError):
68
+ return _OVER
@@ -0,0 +1,409 @@
1
+ """Closed evidence DTO boundary; never inspect arbitrary application objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from typing import Any, TypeGuard
8
+ from urllib.parse import urlsplit
9
+
10
+ from .evidence_json import canonical_size as _size
11
+
12
+ FEATURES = ("exception_evidence_v1", "resource_observations_v1", "recovery_v1", "evidence_provenance_v1")
13
+ _UUID = re.compile(r"^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", re.I)
14
+ _V4 = re.compile(r"^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$", re.I)
15
+ _TIME = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$")
16
+
17
+
18
+ def _mapping(value: Any, maximum: int = 256) -> TypeGuard[dict[str, Any]]:
19
+ return type(value) is dict and len(value) <= maximum and all(type(key) is str for key in value)
20
+
21
+
22
+ def _closed(value: Any, required: tuple[str, ...], optional: tuple[str, ...] = ()) -> bool:
23
+ return (
24
+ _mapping(value, len(required) + len(optional))
25
+ and all(key in value for key in required)
26
+ and all(key in required or key in optional for key in value)
27
+ )
28
+
29
+
30
+ def _one_of(value: Any, options: tuple[str | None, ...]) -> bool:
31
+ return (value is None and None in options) or (type(value) is str and value in options)
32
+
33
+
34
+ def _number(value: Any) -> bool:
35
+ return (
36
+ (type(value) is int or type(value) is float) and 0 <= value <= 1.7976931348623157e308 and math.isfinite(value)
37
+ )
38
+
39
+
40
+ def _integer(value: Any, minimum: int, maximum: int | None = None) -> bool:
41
+ return _number(value) and value >= minimum and (maximum is None or value <= maximum) and value == math.floor(value)
42
+
43
+
44
+ def _text(value: Any, maximum: int) -> bool:
45
+ if type(value) is not str or len(value) > maximum:
46
+ return False
47
+ try:
48
+ return len(value.encode("utf-8")) <= maximum
49
+ except UnicodeError:
50
+ return False
51
+
52
+
53
+ def _uuid(value: Any, fresh: bool = False) -> bool:
54
+ return (
55
+ type(value) is str
56
+ and len(value) == 36
57
+ and (bool(_V4.fullmatch(value)) if fresh else bool(_UUID.fullmatch(value)))
58
+ )
59
+
60
+
61
+ def _timestamp(value: Any) -> bool:
62
+ if not _text(value, 32768) or not _TIME.fullmatch(value):
63
+ return False
64
+ # Native fromisoformat acceptance changed in Python 3.14 (24:00 rolls to
65
+ # the following day). Match the accepting UTC calendar on every floor.
66
+ year, month, day = int(value[:4]), int(value[5:7]), int(value[8:10])
67
+ hour, minute, second = int(value[11:13]), int(value[14:16]), int(value[17:19])
68
+ if not 1 <= month <= 12 or hour > 23 or minute > 59 or second > 59:
69
+ return False
70
+ leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
71
+ days = (31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
72
+ return 1 <= day <= days[month - 1]
73
+
74
+
75
+ def parse_capabilities(value: Any) -> dict[str, Any]:
76
+ empty: dict[str, Any] = {"version": 1, "features": []}
77
+ if not _closed(value, ("version", "features")) or not _integer(value["version"], 1, 1):
78
+ return empty
79
+ features = value["features"]
80
+ if type(features) is not list or len(features) > len(FEATURES):
81
+ return empty
82
+ if any(type(feature) is not str or feature not in FEATURES for feature in features) or len(set(features)) != len(
83
+ features
84
+ ):
85
+ return empty
86
+ return {"version": 1, "features": [feature for feature in FEATURES if feature in features]}
87
+
88
+
89
+ def _scope(value: Any) -> bool:
90
+ if not _mapping(value, 2):
91
+ return False
92
+ field = (
93
+ "page_id"
94
+ if _one_of(value.get("kind"), ("page",))
95
+ else "operation_id"
96
+ if _one_of(value.get("kind"), ("operation",))
97
+ else None
98
+ )
99
+ return field is not None and _closed(value, ("kind", field)) and _uuid(value[field], True)
100
+
101
+
102
+ def reference(value: Any) -> dict[str, Any] | None:
103
+ if (
104
+ _closed(value, ("version", "failure_ref", "scope"))
105
+ and _integer(value["version"], 1, 1)
106
+ and _uuid(value["failure_ref"], True)
107
+ and _scope(value["scope"])
108
+ ):
109
+ return value
110
+ return None
111
+
112
+
113
+ def capture_reference(value: Any, default_scope: Any = None) -> dict[str, Any] | None:
114
+ if not _mapping(value):
115
+ return None
116
+ return reference(
117
+ {"version": 1, "failure_ref": value.get("failure_ref"), "scope": value.get("evidence_scope", default_scope)}
118
+ )
119
+
120
+
121
+ def normalise_recovery(value: Any, default_scope: Any = None) -> dict[str, Any] | None:
122
+ selected = capture_reference(value, default_scope)
123
+ if selected is None:
124
+ return None
125
+ payload = dict(selected, kind="recovery", attempt_id=value.get("attempt_id"), state=value.get("state"))
126
+ for key in ("operation", "status_code", "duration_ms"):
127
+ if key in value:
128
+ payload[key] = value[key]
129
+ return payload if diagnostic(payload) else None
130
+
131
+
132
+ def _url(value: Any) -> bool:
133
+ if (
134
+ not _text(value, 2048)
135
+ or not value
136
+ or value.startswith("//")
137
+ or re.search(r"[\x00-\x20\x7f\\?#]", value)
138
+ or (not value.startswith("/") and not re.match(r"^https?://", value, re.I))
139
+ ):
140
+ return False
141
+ try:
142
+ parsed = urlsplit(value)
143
+ _ = parsed.port
144
+ return (
145
+ (value.startswith("/") or parsed.hostname is not None)
146
+ and not parsed.username
147
+ and not parsed.password
148
+ and not parsed.query
149
+ and not parsed.fragment
150
+ )
151
+ except ValueError:
152
+ return False
153
+
154
+
155
+ def resources(value: Any) -> bool:
156
+ if not _closed(value, ("version", "items")) or not _integer(value["version"], 1, 1):
157
+ return False
158
+ items = value["items"]
159
+ if type(items) is not list or not 1 <= len(items) <= 5:
160
+ return False
161
+ for item in items:
162
+ if (
163
+ not _mapping(item, 8)
164
+ or not _timestamp(item.get("observed_at"))
165
+ or (item.get("source_url") is not None and not _url(item["source_url"]))
166
+ ):
167
+ return False
168
+ if _one_of(item.get("kind"), ("timing",)):
169
+ if (
170
+ not _closed(
171
+ item,
172
+ (
173
+ "kind",
174
+ "observed_at",
175
+ "source_url",
176
+ "availability",
177
+ "response_status",
178
+ "duration_ms",
179
+ "transfer_size_bytes",
180
+ ),
181
+ )
182
+ or not _one_of(
183
+ item["availability"], ("observed", "withheld", "unsupported", "no_match", "ambiguous", "unknown")
184
+ )
185
+ or (item["response_status"] is not None and not _integer(item["response_status"], 100, 599))
186
+ or (item["duration_ms"] is not None and not _number(item["duration_ms"]))
187
+ or (item["transfer_size_bytes"] is not None and not _integer(item["transfer_size_bytes"], 0))
188
+ ):
189
+ return False
190
+ elif _one_of(item.get("kind"), ("csp",)):
191
+ if (
192
+ not _closed(
193
+ item,
194
+ ("kind", "observed_at", "source_url", "effective_directive", "disposition", "document_status_code"),
195
+ )
196
+ or type(item["effective_directive"]) is not str
197
+ or not re.fullmatch(r"[a-z-]{1,64}", item["effective_directive"])
198
+ or not _one_of(item["disposition"], ("report", "enforce"))
199
+ or (item["document_status_code"] is not None and not _integer(item["document_status_code"], 100, 599))
200
+ ):
201
+ return False
202
+ else:
203
+ return False
204
+ return _size(value) <= 32768
205
+
206
+
207
+ def diagnostic(value: Any) -> bool:
208
+ if (
209
+ not _mapping(value, 10)
210
+ or reference({key: value.get(key) for key in ("version", "failure_ref", "scope")}) is None
211
+ ):
212
+ return False
213
+ if _one_of(value.get("kind"), ("recovery",)):
214
+ return (
215
+ _closed(
216
+ value,
217
+ ("version", "kind", "failure_ref", "scope", "attempt_id", "state"),
218
+ ("operation", "status_code", "duration_ms"),
219
+ )
220
+ and _uuid(value["attempt_id"], True)
221
+ and _one_of(
222
+ value["state"],
223
+ ("attempt_started", "attempt_completed", "attempt_failed", "recovery_observed", "failure_persisted"),
224
+ )
225
+ and ("operation" not in value or _text(value["operation"], 64))
226
+ and ("status_code" not in value or _integer(value["status_code"], 100, 599))
227
+ and ("duration_ms" not in value or _number(value["duration_ms"]))
228
+ )
229
+ return (
230
+ _one_of(value.get("kind"), ("resource_observation",))
231
+ and _closed(value, ("version", "kind", "failure_ref", "scope", "source_event_id", "observations"))
232
+ and _uuid(value["source_event_id"])
233
+ and resources(value["observations"])
234
+ )
235
+
236
+
237
+ def execution(value: Any) -> bool:
238
+ return (
239
+ _closed(value, ("version", "kind", "name", "elapsed_ms", "limit_ms"))
240
+ and _integer(value["version"], 1, 1)
241
+ and _one_of(value["kind"], ("request", "command", "job", "unknown"))
242
+ and (value["name"] is None or _text(value["name"], 128))
243
+ and (value["elapsed_ms"] is None or _number(value["elapsed_ms"]))
244
+ and (value["limit_ms"] is None or _number(value["limit_ms"]))
245
+ )
246
+
247
+
248
+ def graph(value: Any) -> bool:
249
+ if (
250
+ not _closed(value, ("version", "runtime", "root_id", "nodes", "truncated", "unavailable_reason"))
251
+ or not _integer(value["version"], 1, 1)
252
+ or not _integer(value["root_id"], 0, 7)
253
+ or not _one_of(
254
+ value["runtime"],
255
+ (
256
+ "node",
257
+ "browser",
258
+ "python",
259
+ "php",
260
+ "jvm",
261
+ "go",
262
+ "ruby",
263
+ "dotnet",
264
+ "android",
265
+ "swift",
266
+ "react_native",
267
+ "unknown",
268
+ ),
269
+ )
270
+ or type(value["truncated"]) is not bool
271
+ or not _one_of(value["unavailable_reason"], (None, "unknown", "unsafe", "unsupported", "budget_exceeded"))
272
+ or type(value["nodes"]) is not list
273
+ or not 1 <= len(value["nodes"]) <= 8
274
+ ):
275
+ return False
276
+ adjacency: dict[int, list[int]] = {}
277
+ for node in value["nodes"]:
278
+ if (
279
+ not _closed(
280
+ node, ("id", "name", "message", "stack", "trace_origin", "parsing_status", "frames", "relationships")
281
+ )
282
+ or not _integer(node["id"], 0, 7)
283
+ or node["id"] in adjacency
284
+ or not _text(node["name"], 256)
285
+ or not _text(node["message"], 2048)
286
+ or (node["stack"] is not None and not _text(node["stack"], 16384))
287
+ or not _one_of(
288
+ node["trace_origin"], ("throw", "native_fatal", "capture", "runtime_reported", "unavailable", "unknown")
289
+ )
290
+ or not _one_of(node["parsing_status"], ("parsed", "partial", "unparsed", "unavailable", "truncated"))
291
+ or type(node["frames"]) is not list
292
+ or len(node["frames"]) > 32
293
+ or type(node["relationships"]) is not list
294
+ or len(node["relationships"]) > 5
295
+ ):
296
+ return False
297
+ for frame in node["frames"]:
298
+ if (
299
+ not _closed(frame, ("function", "file", "line", "column", "classification"))
300
+ or (frame["function"] is not None and not _text(frame["function"], 256))
301
+ or (frame["file"] is not None and not _text(frame["file"], 2048))
302
+ or (frame["line"] is not None and not _integer(frame["line"], 1))
303
+ or (frame["column"] is not None and not _integer(frame["column"], 0))
304
+ or not _one_of(frame["classification"], ("application", "framework", "sdk", "unknown"))
305
+ ):
306
+ return False
307
+ edges: list[int] = []
308
+ seen: set[tuple[str, int]] = set()
309
+ causal = 0
310
+ for edge in node["relationships"]:
311
+ if (
312
+ not _closed(edge, ("kind", "target_id"))
313
+ or not _one_of(edge["kind"], ("cause", "context", "aggregate", "suppressed"))
314
+ or not _integer(edge["target_id"], 0, 7)
315
+ or (edge["kind"], edge["target_id"]) in seen
316
+ ):
317
+ return False
318
+ seen.add((edge["kind"], edge["target_id"]))
319
+ causal += _one_of(edge["kind"], ("cause", "context"))
320
+ edges.append(edge["target_id"])
321
+ if causal > 1:
322
+ return False
323
+ adjacency[node["id"]] = edges
324
+ visited: set[int] = set()
325
+
326
+ def visit(node_id: int, depth: int, ancestors: set[int]) -> bool:
327
+ if node_id not in adjacency or node_id in ancestors or depth > 4:
328
+ return False
329
+ visited.add(node_id)
330
+ return all(visit(child, depth + 1, ancestors | {node_id}) for child in adjacency[node_id])
331
+
332
+ return visit(value["root_id"], 0, set()) and len(visited) == len(adjacency) and _size(value) <= 32768
333
+
334
+
335
+ def validate_evidence(event: dict[str, Any]) -> bool:
336
+ if not _mapping(event):
337
+ return False
338
+ payload = event.get("payload")
339
+ if not _mapping(payload):
340
+ return False
341
+ if _one_of(event.get("event_type"), ("diagnostic_context",)):
342
+ correlation = event.get("correlation")
343
+ session = correlation.get("session_id") if _mapping(correlation) else None
344
+ return diagnostic(payload) and (
345
+ not _one_of(payload["scope"]["kind"], ("page",)) or (type(session) is str and bool(session.strip()))
346
+ )
347
+ additions: dict[str, Any] = {}
348
+ for field, validator in (
349
+ ("exception_evidence", graph),
350
+ ("execution", execution),
351
+ ("resource_observations", resources),
352
+ ):
353
+ if field in payload:
354
+ allowed = (
355
+ field != "resource_observations" and _one_of(event.get("event_type"), ("backend_exception",))
356
+ ) or (field != "execution" and _one_of(event.get("event_type"), ("frontend_exception",)))
357
+ if not allowed:
358
+ return False
359
+ if not validator(payload[field]):
360
+ return False
361
+ additions[field] = payload[field]
362
+ if not additions:
363
+ return True
364
+ context = event.get("context")
365
+ selected = reference(context.get("evidence_reference")) if _mapping(context) else None
366
+ if selected is not None:
367
+ additions["evidence_reference"] = selected
368
+ return _size(additions) <= 32768
369
+
370
+
371
+ def without_optional(event: dict[str, Any]) -> dict[str, Any] | None:
372
+ if not _mapping(event):
373
+ return None
374
+ payload = event.get("payload")
375
+ if not _one_of(event.get("event_type"), ("backend_exception", "frontend_exception")) or not _mapping(payload):
376
+ return None
377
+ fields = ("exception_evidence", "execution", "resource_observations")
378
+ if not any(field in payload for field in fields):
379
+ return None
380
+ return dict(event, payload={key: value for key, value in payload.items() if key not in fields})
381
+
382
+
383
+ def strip_unsupported(event: dict[str, Any], features: list[str]) -> dict[str, Any] | None:
384
+ if not _mapping(event):
385
+ return None
386
+ payload = event.get("payload")
387
+ if not _mapping(payload):
388
+ return None
389
+ if _one_of(event.get("event_type"), ("diagnostic_context",)):
390
+ feature = "recovery_v1" if _one_of(payload.get("kind"), ("recovery",)) else "resource_observations_v1"
391
+ return event if feature in features else None
392
+ payload = dict(payload)
393
+ if "exception_evidence_v1" not in features:
394
+ payload.pop("exception_evidence", None)
395
+ payload.pop("execution", None)
396
+ if "resource_observations_v1" not in features:
397
+ payload.pop("resource_observations", None)
398
+ candidate = dict(event, payload=payload)
399
+ context = event.get("context")
400
+ if (
401
+ "resource_observations_v1" not in features
402
+ and "recovery_v1" not in features
403
+ and _mapping(context)
404
+ and reference(context.get("evidence_reference")) is not None
405
+ ):
406
+ candidate["context"] = {
407
+ key: value for key, value in context.items() if not _one_of(key, ("evidence_reference",))
408
+ }
409
+ return candidate