debugbundle-python 1.2.0__tar.gz → 1.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {debugbundle_python-1.2.0/src/debugbundle_python.egg-info → debugbundle_python-1.3.0}/PKG-INFO +1 -1
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/pyproject.toml +1 -1
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/__init__.py +3 -0
- debugbundle_python-1.3.0/src/debugbundle/acknowledgement.py +71 -0
- debugbundle_python-1.3.0/src/debugbundle/before_send.py +218 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/core.py +139 -323
- debugbundle_python-1.3.0/src/debugbundle/event_support.py +281 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/transport.py +8 -2
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0/src/debugbundle_python.egg-info}/PKG-INFO +1 -1
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle_python.egg-info/SOURCES.txt +4 -0
- debugbundle_python-1.3.0/tests/test_before_send.py +134 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/tests/test_contracts.py +2 -1
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/tests/test_http_integration.py +4 -2
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/tests/test_repository_metadata.py +13 -1
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/tests/test_sdk.py +168 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/tests/test_smoke_script.py +2 -2
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/LICENSE +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/README.md +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/setup.cfg +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/config.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/integrations/__init__.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/integrations/common.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/integrations/django.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/integrations/fastapi.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/integrations/flask.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/integrations/relay_django.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/integrations/relay_fastapi.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/integrations/relay_flask.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/logger_integrations.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/py.typed +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/redaction.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/relay.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/relay_delivery.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/suppression.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle/trigger_token.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle_python.egg-info/dependency_links.txt +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle_python.egg-info/requires.txt +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/src/debugbundle_python.egg-info/top_level.txt +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/tests/test_framework_integrations.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/tests/test_hooks.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/tests/test_optional_imports.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/tests/test_relay.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/tests/test_relay_delivery.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/tests/test_remote_config.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/tests/test_schema_validation.py +0 -0
- {debugbundle_python-1.2.0 → debugbundle_python-1.3.0}/tests/test_trigger_token.py +0 -0
|
@@ -7,6 +7,7 @@ from collections.abc import Callable, Mapping
|
|
|
7
7
|
from contextvars import Token
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
|
+
from .before_send import BeforeSendHook
|
|
10
11
|
from .config import RemoteProbeDirective
|
|
11
12
|
from .core import ConfigFetchResponse, DebugBundleSdk
|
|
12
13
|
from .relay import BrowserRelayAcceptedBatch, BrowserRelayHandler, BrowserRelayResponse
|
|
@@ -30,6 +31,7 @@ def init(
|
|
|
30
31
|
probe_flush_on_error: bool = True,
|
|
31
32
|
fetch_impl: Callable[[str, dict[str, object]], ConfigFetchResponse] | None = None,
|
|
32
33
|
on_diagnostic: Callable[[dict[str, object]], None] | None = None,
|
|
34
|
+
before_send: BeforeSendHook | None = None,
|
|
33
35
|
probes_poll_interval: int = 60_000,
|
|
34
36
|
) -> None:
|
|
35
37
|
_sdk.init(
|
|
@@ -48,6 +50,7 @@ def init(
|
|
|
48
50
|
probe_flush_on_error=probe_flush_on_error,
|
|
49
51
|
fetch_impl=fetch_impl,
|
|
50
52
|
on_diagnostic=on_diagnostic,
|
|
53
|
+
before_send=before_send,
|
|
51
54
|
probes_poll_interval=probes_poll_interval,
|
|
52
55
|
)
|
|
53
56
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal, TypeGuard
|
|
5
|
+
|
|
6
|
+
RETRYABLE_REASONS = {
|
|
7
|
+
"rate_limited",
|
|
8
|
+
"monthly_quota_exceeded",
|
|
9
|
+
"analytics_quota_exceeded",
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class AcknowledgementDecision:
|
|
15
|
+
kind: Literal["legacy", "protocol_failure", "acknowledged"]
|
|
16
|
+
accepted: int = 0
|
|
17
|
+
retryable_indices: tuple[int, ...] = ()
|
|
18
|
+
terminal_errors: tuple[tuple[int, str], ...] = ()
|
|
19
|
+
reason: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def decide_acknowledgement(body: object | None, batch_length: int) -> AcknowledgementDecision:
|
|
23
|
+
if not isinstance(body, dict) or not any(key in body for key in ("accepted", "rejected", "errors")):
|
|
24
|
+
return AcknowledgementDecision(kind="legacy")
|
|
25
|
+
|
|
26
|
+
accepted = body.get("accepted")
|
|
27
|
+
rejected = body.get("rejected")
|
|
28
|
+
errors = body.get("errors")
|
|
29
|
+
if (
|
|
30
|
+
not _is_count(accepted)
|
|
31
|
+
or not _is_count(rejected)
|
|
32
|
+
or not isinstance(errors, list)
|
|
33
|
+
or accepted + rejected != batch_length
|
|
34
|
+
or len(errors) != rejected
|
|
35
|
+
):
|
|
36
|
+
return AcknowledgementDecision(kind="protocol_failure", reason="inconsistent_counts")
|
|
37
|
+
|
|
38
|
+
seen: set[int] = set()
|
|
39
|
+
retryable: list[int] = []
|
|
40
|
+
terminal: list[tuple[int, str]] = []
|
|
41
|
+
for error in errors:
|
|
42
|
+
if not isinstance(error, dict):
|
|
43
|
+
return AcknowledgementDecision(kind="protocol_failure", reason="invalid_error_index")
|
|
44
|
+
index = error.get("index")
|
|
45
|
+
reason = error.get("reason")
|
|
46
|
+
if (
|
|
47
|
+
not isinstance(index, int)
|
|
48
|
+
or isinstance(index, bool)
|
|
49
|
+
or index < 0
|
|
50
|
+
or index >= batch_length
|
|
51
|
+
or index in seen
|
|
52
|
+
or not isinstance(reason, str)
|
|
53
|
+
or not reason
|
|
54
|
+
):
|
|
55
|
+
return AcknowledgementDecision(kind="protocol_failure", reason="invalid_error_index")
|
|
56
|
+
seen.add(index)
|
|
57
|
+
if reason in RETRYABLE_REASONS:
|
|
58
|
+
retryable.append(index)
|
|
59
|
+
else:
|
|
60
|
+
terminal.append((index, reason))
|
|
61
|
+
|
|
62
|
+
return AcknowledgementDecision(
|
|
63
|
+
kind="acknowledged",
|
|
64
|
+
accepted=accepted,
|
|
65
|
+
retryable_indices=tuple(retryable),
|
|
66
|
+
terminal_errors=tuple(terminal),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_count(value: object) -> TypeGuard[int]:
|
|
71
|
+
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import uuid
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
BeforeSendHook = Callable[[dict[str, object]], dict[str, object] | None]
|
|
9
|
+
|
|
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
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def apply_before_send(
|
|
76
|
+
event: dict[str, object],
|
|
77
|
+
hook: BeforeSendHook | None,
|
|
78
|
+
emit_diagnostic: Callable[[str, str], None],
|
|
79
|
+
) -> dict[str, object] | None:
|
|
80
|
+
if hook is None:
|
|
81
|
+
return event
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
result = hook(copy.deepcopy(event))
|
|
85
|
+
except Exception:
|
|
86
|
+
emit_diagnostic("before_send_failed", "Python before_send hook failed")
|
|
87
|
+
return event
|
|
88
|
+
|
|
89
|
+
if result is None:
|
|
90
|
+
return None
|
|
91
|
+
if not _is_valid_event(result):
|
|
92
|
+
emit_diagnostic("before_send_invalid_event", "Python before_send returned an invalid event")
|
|
93
|
+
return event
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
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
|