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/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")
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+