debugbundle-python 0.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 +165 -0
- debugbundle/config.py +192 -0
- debugbundle/core.py +811 -0
- debugbundle/integrations/__init__.py +16 -0
- debugbundle/integrations/common.py +77 -0
- debugbundle/integrations/django.py +53 -0
- debugbundle/integrations/fastapi.py +94 -0
- debugbundle/integrations/flask.py +67 -0
- debugbundle/integrations/relay_django.py +50 -0
- debugbundle/integrations/relay_fastapi.py +46 -0
- debugbundle/integrations/relay_flask.py +46 -0
- debugbundle/logger_integrations.py +154 -0
- debugbundle/py.typed +0 -0
- debugbundle/redaction.py +29 -0
- debugbundle/relay.py +261 -0
- debugbundle/suppression.py +124 -0
- debugbundle/transport.py +59 -0
- debugbundle/trigger_token.py +143 -0
- debugbundle_python-0.1.0.dist-info/METADATA +66 -0
- debugbundle_python-0.1.0.dist-info/RECORD +23 -0
- debugbundle_python-0.1.0.dist-info/WHEEL +5 -0
- debugbundle_python-0.1.0.dist-info/licenses/LICENSE +17 -0
- debugbundle_python-0.1.0.dist-info/top_level.txt +1 -0
debugbundle/relay.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
DEFAULT_MAX_BODY_BYTES = 262_144
|
|
10
|
+
DEFAULT_RATE_LIMIT_PER_MINUTE = 60
|
|
11
|
+
BROWSER_SDK_NAME = "@debugbundle/sdk-browser"
|
|
12
|
+
|
|
13
|
+
ACCEPTED_EVENT_TYPES = frozenset(
|
|
14
|
+
{
|
|
15
|
+
"frontend_exception",
|
|
16
|
+
"error_suppressed",
|
|
17
|
+
"frontend_breadcrumb",
|
|
18
|
+
"probe_event",
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class BrowserRelayResponse:
|
|
25
|
+
status: int
|
|
26
|
+
body: dict[str, Any] | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class BrowserRelayAcceptedBatch:
|
|
31
|
+
events: list[dict[str, Any]]
|
|
32
|
+
headers: dict[str, str]
|
|
33
|
+
ip_address: str | None
|
|
34
|
+
received_at: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class BrowserRelayHandler:
|
|
39
|
+
allowed_origins: list[str] = field(default_factory=list)
|
|
40
|
+
max_body_bytes: int = DEFAULT_MAX_BODY_BYTES
|
|
41
|
+
rate_limit_per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE
|
|
42
|
+
on_accept: Callable[[BrowserRelayAcceptedBatch], None] | None = None
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
self.allowed_origins = [o for o in self.allowed_origins if o]
|
|
46
|
+
self.max_body_bytes = max(1, self.max_body_bytes)
|
|
47
|
+
self.rate_limit_per_minute = max(1, self.rate_limit_per_minute)
|
|
48
|
+
self._rate_limit_state: dict[str, list[int]] = {}
|
|
49
|
+
|
|
50
|
+
def handle(self, request: dict[str, Any]) -> BrowserRelayResponse:
|
|
51
|
+
method = str(request.get("method", "POST")).upper()
|
|
52
|
+
if method != "POST":
|
|
53
|
+
return BrowserRelayResponse(405)
|
|
54
|
+
|
|
55
|
+
headers = _normalize_headers(request.get("headers") or {})
|
|
56
|
+
if not self._is_origin_allowed(headers):
|
|
57
|
+
return BrowserRelayResponse(403)
|
|
58
|
+
|
|
59
|
+
if not _is_supported_content_type(headers.get("content-type")):
|
|
60
|
+
return BrowserRelayResponse(
|
|
61
|
+
400,
|
|
62
|
+
{"accepted": 0, "rejected": 0, "errors": ["Relay requests must use Content-Type: application/json."]},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
body: str = request.get("body", "")
|
|
66
|
+
if len(body.encode("utf-8") if isinstance(body, str) else body) > self.max_body_bytes:
|
|
67
|
+
return BrowserRelayResponse(413)
|
|
68
|
+
|
|
69
|
+
ip_address: str | None = request.get("ipAddress") or request.get("ip_address")
|
|
70
|
+
if self._is_rate_limited(ip_address):
|
|
71
|
+
return BrowserRelayResponse(429)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
decoded = json.loads(body)
|
|
75
|
+
except (json.JSONDecodeError, TypeError, ValueError):
|
|
76
|
+
return BrowserRelayResponse(
|
|
77
|
+
400,
|
|
78
|
+
{"accepted": 0, "rejected": 0, "errors": ["Relay request body must be valid JSON."]},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if not isinstance(decoded, dict):
|
|
82
|
+
return BrowserRelayResponse(
|
|
83
|
+
400,
|
|
84
|
+
{"accepted": 0, "rejected": 0, "errors": ["Relay request body must be valid JSON."]},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
batch = decoded.get("batch")
|
|
88
|
+
if not isinstance(batch, list):
|
|
89
|
+
return BrowserRelayResponse(
|
|
90
|
+
400,
|
|
91
|
+
{"accepted": 0, "rejected": 0, "errors": ["Relay request body must include a batch array."]},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
accepted_events: list[dict[str, Any]] = []
|
|
95
|
+
errors: list[str] = []
|
|
96
|
+
|
|
97
|
+
for index, candidate in enumerate(batch):
|
|
98
|
+
if not isinstance(candidate, dict):
|
|
99
|
+
errors.append(f"batch[{index}]: Relay events must be objects.")
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
event_type = candidate.get("event_type")
|
|
103
|
+
if not isinstance(event_type, str) or event_type not in ACCEPTED_EVENT_TYPES:
|
|
104
|
+
type_label = event_type if isinstance(event_type, str) else "unknown"
|
|
105
|
+
errors.append(f"batch[{index}]: Unsupported browser relay event type {type_label}.")
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
sanitized = _sanitize_event(candidate)
|
|
109
|
+
if sanitized is None:
|
|
110
|
+
errors.append(f"batch[{index}]: Invalid browser relay event payload.")
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
accepted_events.append(sanitized)
|
|
114
|
+
|
|
115
|
+
if accepted_events and self.on_accept is not None:
|
|
116
|
+
self.on_accept(
|
|
117
|
+
BrowserRelayAcceptedBatch(
|
|
118
|
+
events=accepted_events,
|
|
119
|
+
headers=_strip_sensitive_headers(headers),
|
|
120
|
+
ip_address=ip_address,
|
|
121
|
+
received_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if errors:
|
|
126
|
+
return BrowserRelayResponse(
|
|
127
|
+
400,
|
|
128
|
+
{"accepted": len(accepted_events), "rejected": len(errors), "errors": errors},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return BrowserRelayResponse(
|
|
132
|
+
202,
|
|
133
|
+
{"accepted": len(accepted_events), "rejected": 0, "errors": []},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def _is_origin_allowed(self, headers: dict[str, str]) -> bool:
|
|
137
|
+
origin = _source_origin(headers)
|
|
138
|
+
if origin is None:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
if self.allowed_origins:
|
|
142
|
+
normalized_origin = _normalize_origin(origin)
|
|
143
|
+
return any(_normalize_origin(candidate) == normalized_origin for candidate in self.allowed_origins)
|
|
144
|
+
|
|
145
|
+
host = headers.get("host")
|
|
146
|
+
if not host:
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
from urllib.parse import urlparse
|
|
151
|
+
|
|
152
|
+
origin_host = urlparse(origin).hostname
|
|
153
|
+
return isinstance(origin_host, str) and origin_host.lower() == host.lower().split(":")[0]
|
|
154
|
+
except Exception:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
def _is_rate_limited(self, ip_address: str | None) -> bool:
|
|
158
|
+
key = ip_address or "unknown"
|
|
159
|
+
now = int(time.time())
|
|
160
|
+
window_start = now - 60
|
|
161
|
+
timestamps = [ts for ts in self._rate_limit_state.get(key, []) if ts > window_start]
|
|
162
|
+
|
|
163
|
+
if len(timestamps) >= self.rate_limit_per_minute:
|
|
164
|
+
self._rate_limit_state[key] = timestamps
|
|
165
|
+
return True
|
|
166
|
+
|
|
167
|
+
timestamps.append(now)
|
|
168
|
+
self._rate_limit_state[key] = timestamps
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _normalize_headers(headers: dict[str, Any]) -> dict[str, str]:
|
|
173
|
+
return {str(key).lower(): str(value) for key, value in headers.items()}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _is_supported_content_type(content_type: str | None) -> bool:
|
|
177
|
+
return isinstance(content_type, str) and "application/json" in content_type.lower()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _source_origin(headers: dict[str, str]) -> str | None:
|
|
181
|
+
origin = (headers.get("origin") or "").strip()
|
|
182
|
+
if origin:
|
|
183
|
+
return origin
|
|
184
|
+
|
|
185
|
+
referer = (headers.get("referer") or "").strip()
|
|
186
|
+
if not referer:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
from urllib.parse import urlparse
|
|
191
|
+
|
|
192
|
+
parsed = urlparse(referer)
|
|
193
|
+
if parsed.scheme and parsed.hostname:
|
|
194
|
+
return f"{parsed.scheme}://{parsed.hostname}"
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _normalize_origin(origin: str) -> str:
|
|
202
|
+
return origin.strip().lower().rstrip("/")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _strip_sensitive_headers(headers: dict[str, str]) -> dict[str, str]:
|
|
206
|
+
sanitized = dict(headers)
|
|
207
|
+
for key in ("authorization", "cookie", "x-api-key"):
|
|
208
|
+
sanitized.pop(key, None)
|
|
209
|
+
return sanitized
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _sanitize_event(event: dict[str, Any]) -> dict[str, Any] | None:
|
|
213
|
+
schema_version = event.get("schema_version")
|
|
214
|
+
event_id = event.get("event_id")
|
|
215
|
+
event_type = event.get("event_type")
|
|
216
|
+
occurred_at = event.get("occurred_at")
|
|
217
|
+
sdk_version = event.get("sdk_version")
|
|
218
|
+
service = event.get("service")
|
|
219
|
+
payload = event.get("payload")
|
|
220
|
+
|
|
221
|
+
if (
|
|
222
|
+
not isinstance(schema_version, str)
|
|
223
|
+
or not schema_version
|
|
224
|
+
or not isinstance(event_id, str)
|
|
225
|
+
or not event_id
|
|
226
|
+
or not isinstance(event_type, str)
|
|
227
|
+
or not isinstance(occurred_at, str)
|
|
228
|
+
or not occurred_at
|
|
229
|
+
or not isinstance(sdk_version, str)
|
|
230
|
+
or not sdk_version
|
|
231
|
+
or not isinstance(service, dict)
|
|
232
|
+
or not isinstance(payload, dict)
|
|
233
|
+
):
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
service_name = service.get("name")
|
|
237
|
+
environment = service.get("environment")
|
|
238
|
+
if not isinstance(service_name, str) or not service_name or not isinstance(environment, str) or not environment:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
sanitized: dict[str, Any] = {
|
|
242
|
+
"schema_version": schema_version,
|
|
243
|
+
"event_id": event_id,
|
|
244
|
+
"event_type": event_type,
|
|
245
|
+
"occurred_at": occurred_at,
|
|
246
|
+
"sdk_name": BROWSER_SDK_NAME,
|
|
247
|
+
"sdk_version": sdk_version,
|
|
248
|
+
"service": {
|
|
249
|
+
"name": service_name,
|
|
250
|
+
"environment": environment,
|
|
251
|
+
},
|
|
252
|
+
"payload": payload,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
correlation = event.get("correlation")
|
|
256
|
+
if isinstance(correlation, dict):
|
|
257
|
+
trace_id = correlation.get("trace_id")
|
|
258
|
+
if isinstance(trace_id, str) or trace_id is None:
|
|
259
|
+
sanitized["correlation"] = {"trace_id": trace_id}
|
|
260
|
+
|
|
261
|
+
return sanitized
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from hashlib import sha256
|
|
5
|
+
|
|
6
|
+
DUPLICATE_WINDOW_SECONDS = 30.0
|
|
7
|
+
LOOP_WINDOW_SECONDS = 2.0
|
|
8
|
+
LOOP_THRESHOLD = 10
|
|
9
|
+
LOOP_RESET_AFTER_SECONDS = 60.0
|
|
10
|
+
LOOP_CHECKPOINT_SECONDS = 30.0
|
|
11
|
+
MAX_NORMAL_EVENTS_PER_WINDOW = 3
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class SuppressionState:
|
|
16
|
+
window_started_at: float
|
|
17
|
+
emitted_count: int = 0
|
|
18
|
+
pending_suppressed_count: int = 0
|
|
19
|
+
pending_first_seen_at: float | None = None
|
|
20
|
+
pending_last_seen_at: float | None = None
|
|
21
|
+
last_aggregate_emitted_at: float | None = None
|
|
22
|
+
loop_window_started_at: float = 0.0
|
|
23
|
+
loop_hit_count: int = 0
|
|
24
|
+
suppression_mode: bool = False
|
|
25
|
+
last_seen_at: float = 0.0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EventSuppressionTracker:
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._states: dict[str, SuppressionState] = {}
|
|
31
|
+
|
|
32
|
+
def should_capture(self, key: str, now: float) -> bool:
|
|
33
|
+
state = self._states.get(key)
|
|
34
|
+
if state is None:
|
|
35
|
+
state = SuppressionState(
|
|
36
|
+
window_started_at=now,
|
|
37
|
+
loop_window_started_at=now,
|
|
38
|
+
last_seen_at=now,
|
|
39
|
+
)
|
|
40
|
+
self._states[key] = state
|
|
41
|
+
|
|
42
|
+
if state.suppression_mode and now - state.last_seen_at >= LOOP_RESET_AFTER_SECONDS:
|
|
43
|
+
self._states[key] = SuppressionState(
|
|
44
|
+
window_started_at=now,
|
|
45
|
+
loop_window_started_at=now,
|
|
46
|
+
last_seen_at=now,
|
|
47
|
+
)
|
|
48
|
+
state = self._states[key]
|
|
49
|
+
|
|
50
|
+
if now - state.window_started_at >= DUPLICATE_WINDOW_SECONDS:
|
|
51
|
+
state.window_started_at = now
|
|
52
|
+
state.emitted_count = 0
|
|
53
|
+
|
|
54
|
+
if now - state.loop_window_started_at >= LOOP_WINDOW_SECONDS:
|
|
55
|
+
state.loop_window_started_at = now
|
|
56
|
+
state.loop_hit_count = 0
|
|
57
|
+
|
|
58
|
+
state.loop_hit_count += 1
|
|
59
|
+
state.last_seen_at = now
|
|
60
|
+
|
|
61
|
+
if state.loop_hit_count > LOOP_THRESHOLD:
|
|
62
|
+
state.suppression_mode = True
|
|
63
|
+
|
|
64
|
+
if state.suppression_mode:
|
|
65
|
+
self._mark_suppressed(state, now)
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
if state.emitted_count < MAX_NORMAL_EVENTS_PER_WINDOW:
|
|
69
|
+
state.emitted_count += 1
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
self._mark_suppressed(state, now)
|
|
73
|
+
return False
|
|
74
|
+
|
|
75
|
+
def drain_aggregates(self, now: float) -> list[dict[str, object]]:
|
|
76
|
+
aggregates: list[dict[str, object]] = []
|
|
77
|
+
|
|
78
|
+
for key, state in self._states.items():
|
|
79
|
+
if (
|
|
80
|
+
state.pending_suppressed_count == 0
|
|
81
|
+
or state.pending_first_seen_at is None
|
|
82
|
+
or state.pending_last_seen_at is None
|
|
83
|
+
):
|
|
84
|
+
continue
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
state.suppression_mode
|
|
88
|
+
and state.last_aggregate_emitted_at is not None
|
|
89
|
+
and now - state.last_aggregate_emitted_at < LOOP_CHECKPOINT_SECONDS
|
|
90
|
+
):
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
aggregates.append(
|
|
94
|
+
{
|
|
95
|
+
"event_type": "error_suppressed",
|
|
96
|
+
"payload": {
|
|
97
|
+
"fingerprint": sha256(key.encode("utf-8")).hexdigest(),
|
|
98
|
+
"suppressed_count": state.pending_suppressed_count,
|
|
99
|
+
"first_seen": _to_iso(state.pending_first_seen_at),
|
|
100
|
+
"last_seen": _to_iso(state.pending_last_seen_at),
|
|
101
|
+
"window_seconds": int(DUPLICATE_WINDOW_SECONDS),
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
state.pending_suppressed_count = 0
|
|
107
|
+
state.pending_first_seen_at = None
|
|
108
|
+
state.pending_last_seen_at = None
|
|
109
|
+
state.last_aggregate_emitted_at = now
|
|
110
|
+
|
|
111
|
+
return aggregates
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _mark_suppressed(state: SuppressionState, now: float) -> None:
|
|
115
|
+
if state.pending_suppressed_count == 0:
|
|
116
|
+
state.pending_first_seen_at = state.window_started_at
|
|
117
|
+
state.pending_suppressed_count += 1
|
|
118
|
+
state.pending_last_seen_at = now
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _to_iso(timestamp: float) -> str:
|
|
122
|
+
from datetime import datetime, timezone
|
|
123
|
+
|
|
124
|
+
return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
debugbundle/transport.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class TransportResponse:
|
|
12
|
+
status_code: int
|
|
13
|
+
retry_after_ms: int | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Transport(Protocol):
|
|
17
|
+
def __call__(self, request: Mapping[str, object]) -> TransportResponse:
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HttpTransport:
|
|
22
|
+
def __init__(self, endpoint: str) -> None:
|
|
23
|
+
self._endpoint = endpoint
|
|
24
|
+
self._client = httpx.Client(timeout=5.0)
|
|
25
|
+
|
|
26
|
+
def __call__(self, request: Mapping[str, object]) -> TransportResponse:
|
|
27
|
+
headers = {
|
|
28
|
+
"authorization": f"Bearer {request['project_token']}",
|
|
29
|
+
"content-type": "application/json",
|
|
30
|
+
}
|
|
31
|
+
response = self._client.post(
|
|
32
|
+
self._endpoint,
|
|
33
|
+
json={"events": request["events"]},
|
|
34
|
+
headers=headers,
|
|
35
|
+
)
|
|
36
|
+
retry_after_header = response.headers.get("retry-after")
|
|
37
|
+
retry_after_ms = None
|
|
38
|
+
if retry_after_header is not None:
|
|
39
|
+
try:
|
|
40
|
+
retry_after_ms = int(float(retry_after_header) * 1000)
|
|
41
|
+
except ValueError:
|
|
42
|
+
retry_after_ms = None
|
|
43
|
+
|
|
44
|
+
return TransportResponse(status_code=response.status_code, retry_after_ms=retry_after_ms)
|
|
45
|
+
|
|
46
|
+
def close(self) -> None:
|
|
47
|
+
self._client.close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def coerce_transport_response(response: Any) -> TransportResponse:
|
|
51
|
+
if isinstance(response, TransportResponse):
|
|
52
|
+
return response
|
|
53
|
+
|
|
54
|
+
status_code = getattr(response, "status_code", None)
|
|
55
|
+
retry_after_ms = getattr(response, "retry_after_ms", None)
|
|
56
|
+
if isinstance(status_code, int):
|
|
57
|
+
return TransportResponse(status_code=status_code, retry_after_ms=retry_after_ms)
|
|
58
|
+
|
|
59
|
+
raise TypeError("Unsupported transport response")
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .config import RemoteProbeDirective, _as_non_empty_string, _expires_at_ms
|
|
10
|
+
|
|
11
|
+
HEADER_NAME = "x-debugbundle-probe-trigger"
|
|
12
|
+
QUERY_PARAMETER_NAME = "_debug_probe"
|
|
13
|
+
TOKEN_PREFIX = "dbundle_probe_"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def resolve_request_trigger_directives(
|
|
17
|
+
request: dict[str, Any] | None,
|
|
18
|
+
trigger_token_key: str | None,
|
|
19
|
+
now_ms: int,
|
|
20
|
+
) -> list[RemoteProbeDirective]:
|
|
21
|
+
if request is None or not trigger_token_key:
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
token = _extract_trigger_token(request)
|
|
25
|
+
if token is None or not token.startswith(TOKEN_PREFIX):
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
encoded = token[len(TOKEN_PREFIX) :]
|
|
29
|
+
separator_index = encoded.find(".")
|
|
30
|
+
if separator_index <= 0 or separator_index == len(encoded) - 1:
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
payload_segment = encoded[:separator_index]
|
|
34
|
+
signature_segment = encoded[separator_index + 1 :]
|
|
35
|
+
if not _has_valid_signature(payload_segment, signature_segment, trigger_token_key):
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
payload = _decode_payload_segment(payload_segment)
|
|
39
|
+
if payload is None or _expires_at_ms(payload["trigger_expires_at"]) <= now_ms:
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
return [
|
|
43
|
+
RemoteProbeDirective(
|
|
44
|
+
id=payload["activation_id"],
|
|
45
|
+
label_pattern=payload["label_pattern"],
|
|
46
|
+
service=payload["service"],
|
|
47
|
+
environment=payload["environment"],
|
|
48
|
+
expires_at=payload["trigger_expires_at"],
|
|
49
|
+
)
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _extract_trigger_token(request: dict[str, Any]) -> str | None:
|
|
54
|
+
headers = request.get("headers")
|
|
55
|
+
if isinstance(headers, dict):
|
|
56
|
+
header_token = _extract_map_value(headers, HEADER_NAME, case_insensitive=True)
|
|
57
|
+
if header_token is not None:
|
|
58
|
+
return header_token
|
|
59
|
+
|
|
60
|
+
query = request.get("query")
|
|
61
|
+
if isinstance(query, dict):
|
|
62
|
+
return _extract_map_value(query, QUERY_PARAMETER_NAME, case_insensitive=False)
|
|
63
|
+
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _decode_payload_segment(payload_segment: str) -> dict[str, str] | None:
|
|
68
|
+
try:
|
|
69
|
+
decoded = _base64url_decode(payload_segment)
|
|
70
|
+
if decoded is None:
|
|
71
|
+
return None
|
|
72
|
+
parsed = json.loads(decoded)
|
|
73
|
+
except Exception:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
if not isinstance(parsed, dict):
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
activation_id = _as_non_empty_string(parsed.get("activation_id"))
|
|
80
|
+
label_pattern = _as_non_empty_string(parsed.get("label_pattern"))
|
|
81
|
+
service = _as_non_empty_string(parsed.get("service"))
|
|
82
|
+
environment = _as_non_empty_string(parsed.get("environment"))
|
|
83
|
+
expires_at = _as_non_empty_string(parsed.get("trigger_expires_at"))
|
|
84
|
+
|
|
85
|
+
if activation_id is None or label_pattern is None or service is None or environment is None or expires_at is None:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
if _expires_at_ms(expires_at) == 0:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
"activation_id": activation_id,
|
|
93
|
+
"label_pattern": label_pattern,
|
|
94
|
+
"service": service,
|
|
95
|
+
"environment": environment,
|
|
96
|
+
"trigger_expires_at": expires_at,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _has_valid_signature(payload_segment: str, signature_segment: str, trigger_token_key: str) -> bool:
|
|
101
|
+
expected = hmac.new(trigger_token_key.encode("utf-8"), payload_segment.encode("utf-8"), hashlib.sha256).digest()
|
|
102
|
+
actual = _base64url_decode_bytes(signature_segment)
|
|
103
|
+
if actual is None or len(expected) != len(actual):
|
|
104
|
+
return False
|
|
105
|
+
return hmac.compare_digest(expected, actual)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _extract_map_value(mapping: dict[str, Any], key: str, *, case_insensitive: bool) -> str | None:
|
|
109
|
+
for candidate_key, value in mapping.items():
|
|
110
|
+
if case_insensitive:
|
|
111
|
+
matches = str(candidate_key).lower() == key.lower()
|
|
112
|
+
else:
|
|
113
|
+
matches = str(candidate_key) == key
|
|
114
|
+
|
|
115
|
+
if not matches:
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
if isinstance(value, str) and value:
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
if isinstance(value, (list, tuple)):
|
|
122
|
+
for entry in value:
|
|
123
|
+
if isinstance(entry, str) and entry:
|
|
124
|
+
return entry
|
|
125
|
+
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _base64url_decode(value: str) -> str | None:
|
|
130
|
+
raw = _base64url_decode_bytes(value)
|
|
131
|
+
if raw is None:
|
|
132
|
+
return None
|
|
133
|
+
return raw.decode("utf-8")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _base64url_decode_bytes(value: str) -> bytes | None:
|
|
137
|
+
try:
|
|
138
|
+
padding = len(value) % 4
|
|
139
|
+
if padding > 0:
|
|
140
|
+
value += "=" * (4 - padding)
|
|
141
|
+
return base64.urlsafe_b64decode(value)
|
|
142
|
+
except Exception:
|
|
143
|
+
return None
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: debugbundle-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: DebugBundle SDK for Python
|
|
5
|
+
Author: DebugBundle
|
|
6
|
+
License-Expression: AGPL-3.0-only
|
|
7
|
+
Project-URL: Homepage, https://debugbundle.com/docs/sdk-python
|
|
8
|
+
Project-URL: Repository, https://github.com/debugbundle/debugbundle-python
|
|
9
|
+
Project-URL: Issues, https://github.com/debugbundle/debugbundle-python/issues
|
|
10
|
+
Keywords: debugbundle,debugging,ai-agent,error-tracking
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Framework :: Flask
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: httpx<0.29,>=0.27
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: build<2,>=1; extra == "dev"
|
|
27
|
+
Requires-Dist: django<6,>=5; extra == "dev"
|
|
28
|
+
Requires-Dist: fastapi<1,>=0.115; extra == "dev"
|
|
29
|
+
Requires-Dist: flask<4,>=3; extra == "dev"
|
|
30
|
+
Requires-Dist: jsonschema<5,>=4.23; extra == "dev"
|
|
31
|
+
Requires-Dist: loguru<1,>=0.7; extra == "dev"
|
|
32
|
+
Requires-Dist: mypy<2,>=1.15; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest<9,>=8.3; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-cov<7,>=5; extra == "dev"
|
|
35
|
+
Requires-Dist: ruff<0.12,>=0.11; extra == "dev"
|
|
36
|
+
Requires-Dist: structlog<26,>=24; extra == "dev"
|
|
37
|
+
Requires-Dist: twine<7,>=5; extra == "dev"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# debugbundle-python
|
|
41
|
+
|
|
42
|
+
DebugBundle SDK for Python.
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install debugbundle-python
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import debugbundle
|
|
54
|
+
|
|
55
|
+
debugbundle.init(project_token="dbundle_proj_test", service="checkout-api")
|
|
56
|
+
debugbundle.capture_exception(RuntimeError("boom"))
|
|
57
|
+
debugbundle.flush()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Status
|
|
61
|
+
|
|
62
|
+
This repository currently contains the full Phase 18 Python SDK scope in eleven implementation slices: core SDK surface, buffering, redaction, duplicate suppression, probe buffering, vanilla runtime hooks, framework integrations for Django, Flask, and FastAPI, remote config polling and capture-policy enforcement, optional `structlog` and `loguru` auto-detection when `capture_logging()` is enabled, contract-aligned `EventEnvelope` emission for log, request, exception, suppression, and probe payloads, explicit public wrapper signatures and a validated buildable typed package artifact, real HTTP integration coverage against a lightweight mock ingestion server, vendored machine-readable schema validation for all event types the Python SDK currently emits, a standalone CI workflow that validates Ruff, mypy, pytest, and package builds for the Python 3.10+ support floor actually used by the package, an enforced per-file coverage gate that keeps every shipped Python SDK module at or above the required 80% minimum, and request-local framework correlation binding so `X-DebugBundle-Trace-Id` flows through Django, Flask, and FastAPI into the emitted event correlation metadata for cross-context linking.
|
|
63
|
+
|
|
64
|
+
## Docs
|
|
65
|
+
|
|
66
|
+
https://debugbundle.com/docs/sdk-python
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
debugbundle/__init__.py,sha256=mqibduUrdcr_fnb6k3Gwf93H7iqVBS8zd6SceX-U3Q4,5011
|
|
2
|
+
debugbundle/config.py,sha256=tmXJc16v9yzldiyp1Fwo2s0j-Ek-wbKx__BeKaNw9ec,6097
|
|
3
|
+
debugbundle/core.py,sha256=CTx3MmJv0x3fZr0IqTqliv0slTqmia9QVb6eEm4RPTQ,32628
|
|
4
|
+
debugbundle/logger_integrations.py,sha256=RuTNaD9RRVmiE-BBkksAXWVEGaMzLrWavVpQdgGZBpE,4564
|
|
5
|
+
debugbundle/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
debugbundle/redaction.py,sha256=QTaPkSsYv54yR1mz8SB6Q4vWjvp7Ddcui4WXaH2RVz8,736
|
|
7
|
+
debugbundle/relay.py,sha256=q7WjESsIGoaHETQGhWuT-4KdBmXAocuoCLXuEaz9f60,8678
|
|
8
|
+
debugbundle/suppression.py,sha256=XMn0GfF_-WZk2wHWk3KfbO5_u5QhEunues5h3jOInLs,4098
|
|
9
|
+
debugbundle/transport.py,sha256=oOk0xazHxq9h4CneJdRODKtwDciwikvpHVJM_6iBYXU,1791
|
|
10
|
+
debugbundle/trigger_token.py,sha256=YUwIWnnxu1klAVaB8Ltgk_PwWW_PPjq9rzmEbWXeFeM,4455
|
|
11
|
+
debugbundle/integrations/__init__.py,sha256=VVNoL30a9yd26U9oH1CQMw0_0O0Apo-ZNK1dyrMjTQg,551
|
|
12
|
+
debugbundle/integrations/common.py,sha256=iiwf5wDlpujFuWfbO-ziFTn4MtiHBXSIZNXtamhCavg,2014
|
|
13
|
+
debugbundle/integrations/django.py,sha256=vDQGc0ObcHCe4NQbC0ijcXUM-y9GzOVBjDtcHzxyrxw,1828
|
|
14
|
+
debugbundle/integrations/fastapi.py,sha256=-054Z6MogkZIqKR8DHR4jm2nNC5yyZ5vtanpw6g-voE,3414
|
|
15
|
+
debugbundle/integrations/flask.py,sha256=cgyHbsAH96gpNPf_5IGJYzp2va28JdDgXROBsF-Fbyo,2390
|
|
16
|
+
debugbundle/integrations/relay_django.py,sha256=_2TiH-oS0DOwThyfMJZ6ZJr5MkXEekEDaBUfPapMxt8,1523
|
|
17
|
+
debugbundle/integrations/relay_fastapi.py,sha256=Z2spWy3wufVrBjYwRDDOL5Dsjgwcd5g72L23_sdulvo,1512
|
|
18
|
+
debugbundle/integrations/relay_flask.py,sha256=WHk_ALOtT0xUuI1eINWFyLig9eRjSmIBpQ39xdkV608,1329
|
|
19
|
+
debugbundle_python-0.1.0.dist-info/licenses/LICENSE,sha256=AKZZ5DQAHrOKGwt24VoRd-SXJLM9OloxlsX8ZgENdEY,735
|
|
20
|
+
debugbundle_python-0.1.0.dist-info/METADATA,sha256=LtOnh1fp34Nt1M6Cl-K3DO9IJNEjyj6q3OXmAU9VFv4,3148
|
|
21
|
+
debugbundle_python-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
22
|
+
debugbundle_python-0.1.0.dist-info/top_level.txt,sha256=RCB9STTFnl1OKdojxz-xhaks2zkRFs1meZXsKnm18LM,12
|
|
23
|
+
debugbundle_python-0.1.0.dist-info/RECORD,,
|