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.
- debugbundle/__init__.py +16 -0
- debugbundle/before_send.py +35 -191
- debugbundle/canonical_event.py +202 -0
- debugbundle/config.py +6 -0
- debugbundle/core.py +76 -318
- debugbundle/delivery_hooks.py +14 -0
- debugbundle/diagnostic_budget.py +191 -0
- debugbundle/event-envelope.schema.json +4904 -0
- debugbundle/evidence_delivery.py +115 -0
- debugbundle/evidence_http.py +45 -0
- debugbundle/evidence_json.py +68 -0
- debugbundle/evidence_wire.py +409 -0
- debugbundle/exception_capture.py +92 -0
- debugbundle/exception_evidence.py +199 -0
- debugbundle/exception_snapshot.py +1 -1
- debugbundle/flush_delivery.py +203 -0
- debugbundle/ingestion_wire.py +75 -0
- debugbundle/integrations/relay_django.py +4 -1
- debugbundle/integrations/relay_fastapi.py +10 -2
- debugbundle/integrations/relay_flask.py +4 -1
- debugbundle/operation_context.py +121 -0
- debugbundle/process_support.py +7 -0
- debugbundle/queue_admission.py +42 -2
- debugbundle/recovery_capture.py +43 -0
- debugbundle/relay.py +93 -36
- debugbundle/relay_delivery.py +108 -17
- debugbundle/remote_config_client.py +81 -0
- debugbundle/scope_ownership.py +99 -0
- debugbundle/transport.py +20 -14
- {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/METADATA +116 -1
- debugbundle_python-2.1.0.dist-info/RECORD +51 -0
- {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/WHEEL +1 -1
- debugbundle_python-2.0.1.dist-info/RECORD +0 -36
- {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
- {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/top_level.txt +0 -0
|
@@ -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
|