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
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
|
]
|
debugbundle/before_send.py
CHANGED
|
@@ -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
|
|
5
|
-
from
|
|
6
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
57
|
+
return cast(dict[str, object], checked)
|
|
95
58
|
|
|
96
59
|
|
|
97
60
|
def _is_valid_event(event: object) -> bool:
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|