debugbundle-python 2.0.0__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/acknowledgement.py +3 -1
- debugbundle/before_send.py +35 -191
- debugbundle/canonical_event.py +202 -0
- debugbundle/config.py +6 -0
- debugbundle/core.py +76 -316
- debugbundle/delivery_hooks.py +14 -0
- debugbundle/diagnostic_budget.py +191 -0
- debugbundle/event-envelope.schema.json +4904 -0
- debugbundle/event_support.py +1 -1
- 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 +49 -20
- {debugbundle_python-2.0.0.dist-info → debugbundle_python-2.1.0.dist-info}/METADATA +122 -1
- debugbundle_python-2.1.0.dist-info/RECORD +51 -0
- {debugbundle_python-2.0.0.dist-info → debugbundle_python-2.1.0.dist-info}/WHEEL +1 -1
- debugbundle_python-2.0.0.dist-info/RECORD +0 -36
- {debugbundle_python-2.0.0.dist-info → debugbundle_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
- {debugbundle_python-2.0.0.dist-info → debugbundle_python-2.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Monotonic operation ownership; retain only bounded technical metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from contextvars import ContextVar, Token
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Operation:
|
|
17
|
+
revision: int
|
|
18
|
+
scope: dict[str, str]
|
|
19
|
+
kind: str
|
|
20
|
+
name: str | None
|
|
21
|
+
limit: float | int | None
|
|
22
|
+
started: float
|
|
23
|
+
parent: Operation | None
|
|
24
|
+
active: bool = True
|
|
25
|
+
token_id: int = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OperationContexts:
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._current: ContextVar[Operation | None] = ContextVar("debugbundle_operation", default=None)
|
|
31
|
+
self._revision = 0
|
|
32
|
+
|
|
33
|
+
def clear(self) -> None:
|
|
34
|
+
self._revision += 1
|
|
35
|
+
|
|
36
|
+
def current(self) -> Operation | None:
|
|
37
|
+
current = self._current.get()
|
|
38
|
+
ancestor = current
|
|
39
|
+
for _ in range(16):
|
|
40
|
+
if ancestor is None:
|
|
41
|
+
return current
|
|
42
|
+
if not ancestor.active or ancestor.revision != self._revision:
|
|
43
|
+
return None
|
|
44
|
+
ancestor = ancestor.parent
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
def begin(self, options: object) -> Token[Operation | None] | None:
|
|
48
|
+
try:
|
|
49
|
+
values: dict[str, Any] = options if type(options) is dict else {}
|
|
50
|
+
kind = values.get("kind")
|
|
51
|
+
kind = kind if type(kind) is str and kind in ("request", "command", "job", "unknown") else "unknown"
|
|
52
|
+
name = values.get("name")
|
|
53
|
+
name = name if type(name) is str and name.strip() and len(name.encode("utf-8")) <= 128 else None
|
|
54
|
+
limit = values.get("limit_ms")
|
|
55
|
+
limit = (
|
|
56
|
+
limit
|
|
57
|
+
if (type(limit) is int or type(limit) is float)
|
|
58
|
+
and 0 <= limit <= 1.7976931348623157e308
|
|
59
|
+
and math.isfinite(limit)
|
|
60
|
+
else None
|
|
61
|
+
)
|
|
62
|
+
parent = self.current()
|
|
63
|
+
# Count-bound inherited metadata as well as serialized fields.
|
|
64
|
+
depth, ancestor = 0, parent
|
|
65
|
+
while ancestor is not None:
|
|
66
|
+
depth += 1
|
|
67
|
+
ancestor = ancestor.parent
|
|
68
|
+
if depth >= 15:
|
|
69
|
+
return None
|
|
70
|
+
value = Operation(
|
|
71
|
+
self._revision,
|
|
72
|
+
{"kind": "operation", "operation_id": str(uuid.uuid4())},
|
|
73
|
+
kind,
|
|
74
|
+
name,
|
|
75
|
+
limit,
|
|
76
|
+
time.monotonic(),
|
|
77
|
+
parent,
|
|
78
|
+
)
|
|
79
|
+
token = self._current.set(value)
|
|
80
|
+
value.token_id = id(token)
|
|
81
|
+
return token
|
|
82
|
+
except BaseException:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def end(self, token: Token[Operation | None] | None) -> None:
|
|
86
|
+
if token is None:
|
|
87
|
+
return
|
|
88
|
+
current = self._current.get()
|
|
89
|
+
value = current
|
|
90
|
+
for _ in range(16):
|
|
91
|
+
if value is None:
|
|
92
|
+
return
|
|
93
|
+
if value.token_id == id(token):
|
|
94
|
+
if value is current:
|
|
95
|
+
self._current.reset(token)
|
|
96
|
+
value.active = False
|
|
97
|
+
return
|
|
98
|
+
value = value.parent
|
|
99
|
+
|
|
100
|
+
def snapshot(self) -> dict[str, object] | None:
|
|
101
|
+
value = self.current()
|
|
102
|
+
if value is None:
|
|
103
|
+
return None
|
|
104
|
+
return {
|
|
105
|
+
"version": 1,
|
|
106
|
+
"kind": value.kind,
|
|
107
|
+
"name": value.name,
|
|
108
|
+
"elapsed_ms": max(0, (time.monotonic() - value.started) * 1000),
|
|
109
|
+
"limit_ms": value.limit,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@contextmanager
|
|
113
|
+
def context(self, options: object) -> Iterator[None]:
|
|
114
|
+
token = self.begin(options)
|
|
115
|
+
try:
|
|
116
|
+
yield
|
|
117
|
+
finally:
|
|
118
|
+
try:
|
|
119
|
+
self.end(token)
|
|
120
|
+
except BaseException:
|
|
121
|
+
pass
|
debugbundle/process_support.py
CHANGED
|
@@ -7,6 +7,9 @@ import threading
|
|
|
7
7
|
from contextvars import ContextVar
|
|
8
8
|
from typing import TYPE_CHECKING
|
|
9
9
|
|
|
10
|
+
from .diagnostic_budget import DiagnosticCaptureBudget
|
|
11
|
+
from .evidence_delivery import EvidenceDelivery
|
|
12
|
+
from .operation_context import OperationContexts
|
|
10
13
|
from .suppression import EventSuppressionTracker
|
|
11
14
|
from .transport import HttpTransport
|
|
12
15
|
|
|
@@ -27,6 +30,10 @@ def ensure_current_process(sdk: DebugBundleSdk) -> None:
|
|
|
27
30
|
sdk._initial_config_ready = threading.Event()
|
|
28
31
|
sdk._initial_config_ready.set()
|
|
29
32
|
sdk._generation += 1
|
|
33
|
+
sdk._operations = OperationContexts()
|
|
34
|
+
sdk._evidence = EvidenceDelivery()
|
|
35
|
+
sdk._diagnostics = DiagnosticCaptureBudget()
|
|
36
|
+
sdk._hook_bypass = set()
|
|
30
37
|
sdk._sending = False
|
|
31
38
|
sdk._inflight_count = 0
|
|
32
39
|
sdk._timer_due_at = 0.0
|
debugbundle/queue_admission.py
CHANGED
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
|
+
from .evidence_delivery import reconcile_diagnostic
|
|
8
|
+
|
|
7
9
|
if TYPE_CHECKING:
|
|
8
10
|
from .core import DebugBundleSdk
|
|
9
11
|
|
|
@@ -14,8 +16,11 @@ def high_priority_event(event: dict[str, object]) -> bool:
|
|
|
14
16
|
payload = event.get("payload")
|
|
15
17
|
return isinstance(payload, dict) and (
|
|
16
18
|
payload.get("level") in ("error", "critical")
|
|
17
|
-
or (
|
|
18
|
-
|
|
19
|
+
or (
|
|
20
|
+
event.get("event_type") == "request_event"
|
|
21
|
+
and type(payload.get("response_status")) is int
|
|
22
|
+
and payload["response_status"] >= 500
|
|
23
|
+
)
|
|
19
24
|
)
|
|
20
25
|
|
|
21
26
|
|
|
@@ -32,8 +37,43 @@ def evict_low_priority(sdk: DebugBundleSdk) -> bool:
|
|
|
32
37
|
for index in range(sdk._inflight_count, len(sdk._buffer)):
|
|
33
38
|
if not high_priority_event(sdk._buffer[index]):
|
|
34
39
|
evicted = sdk._buffer.pop(index)
|
|
40
|
+
sdk._hook_bypass.discard(id(evicted))
|
|
41
|
+
if evicted.get("event_type") == "diagnostic_context":
|
|
42
|
+
sdk._diagnostics.release([str(evicted["event_id"])])
|
|
35
43
|
sdk._buffer_bytes -= sdk._buffer_sizes.pop(id(evicted))
|
|
36
44
|
sdk._pending_low_priority -= 1
|
|
37
45
|
sdk._pressure_drops = min(2**63 - 1, sdk._pressure_drops + 1)
|
|
38
46
|
return True
|
|
39
47
|
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def offer_protected_event(
|
|
51
|
+
sdk: DebugBundleSdk,
|
|
52
|
+
protected: dict[str, object],
|
|
53
|
+
event_bytes: int,
|
|
54
|
+
max_events: int,
|
|
55
|
+
max_bytes: int,
|
|
56
|
+
) -> bool:
|
|
57
|
+
if event_bytes > max_bytes:
|
|
58
|
+
sdk._pressure_drops += 1
|
|
59
|
+
return False
|
|
60
|
+
high_priority = high_priority_event(protected)
|
|
61
|
+
under_pressure = len(sdk._buffer) >= max_events or sdk._buffer_bytes + event_bytes > max_bytes
|
|
62
|
+
while len(sdk._buffer) >= max_events or sdk._buffer_bytes + event_bytes > max_bytes:
|
|
63
|
+
if not high_priority or not evict_low_priority(sdk):
|
|
64
|
+
sdk._pressure_drops += 1
|
|
65
|
+
return False
|
|
66
|
+
insertion = sdk._inflight_count
|
|
67
|
+
if not reconcile_diagnostic(sdk, None, protected):
|
|
68
|
+
return False
|
|
69
|
+
if high_priority and (protected.get("event_type") != "request_event" or under_pressure):
|
|
70
|
+
while insertion < len(sdk._buffer) and high_priority_event(sdk._buffer[insertion]):
|
|
71
|
+
insertion += 1
|
|
72
|
+
sdk._buffer.insert(insertion, protected)
|
|
73
|
+
else:
|
|
74
|
+
sdk._buffer.append(protected)
|
|
75
|
+
sdk._pending_low_priority += 1
|
|
76
|
+
sdk._buffer_bytes += event_bytes
|
|
77
|
+
sdk._buffer_sizes[id(protected)] = event_bytes
|
|
78
|
+
sdk._schedule_flush_locked(delay=0.0 if len(sdk._buffer) >= sdk._batch_size else None)
|
|
79
|
+
return True
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Explicit application observations share protected queue ownership."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from .capture_hooks import try_capture_lock
|
|
8
|
+
from .evidence_delivery import allows_recovery
|
|
9
|
+
from .evidence_wire import normalise_recovery
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .core import DebugBundleSdk
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def capture_recovery(sdk: DebugBundleSdk, observation: object) -> None:
|
|
16
|
+
try:
|
|
17
|
+
if not sdk._enabled or not allows_recovery(sdk) or not sdk._passes_sample_rate():
|
|
18
|
+
return
|
|
19
|
+
operation = sdk._operations.current()
|
|
20
|
+
payload = normalise_recovery(observation, operation.scope if operation is not None else None)
|
|
21
|
+
if payload is None:
|
|
22
|
+
return
|
|
23
|
+
correlation = observation.get("correlation") if type(observation) is dict else None
|
|
24
|
+
# Correlation remains metadata and must never invoke application mappings.
|
|
25
|
+
context = (
|
|
26
|
+
{
|
|
27
|
+
key: value
|
|
28
|
+
for key, value in correlation.items()
|
|
29
|
+
if key in ("request_id", "trace_id", "session_id", "user_id_hash")
|
|
30
|
+
and (value is None or type(value) is str)
|
|
31
|
+
}
|
|
32
|
+
if type(correlation) is dict and len(correlation) <= 4 and all(type(key) is str for key in correlation)
|
|
33
|
+
else {}
|
|
34
|
+
)
|
|
35
|
+
event = sdk._protect_event(sdk._base_event("diagnostic_context", payload, context=context))
|
|
36
|
+
if event is None or not try_capture_lock(sdk):
|
|
37
|
+
return
|
|
38
|
+
try:
|
|
39
|
+
sdk._offer_protected_event(event, sdk._event_bytes(event))
|
|
40
|
+
finally:
|
|
41
|
+
sdk._lock.release()
|
|
42
|
+
except BaseException:
|
|
43
|
+
return
|
debugbundle/relay.py
CHANGED
|
@@ -5,7 +5,11 @@ import time
|
|
|
5
5
|
from collections.abc import Callable, Mapping
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
7
|
from typing import Any
|
|
8
|
+
from urllib.parse import parse_qs, urlsplit
|
|
8
9
|
|
|
10
|
+
from .before_send import _is_valid_event
|
|
11
|
+
from .evidence_delivery import EvidenceDelivery
|
|
12
|
+
from .evidence_wire import strip_unsupported, validate_evidence, without_optional
|
|
9
13
|
from .redaction import UnsafeTelemetry, has_safe_event_identity, sanitize_telemetry
|
|
10
14
|
from .relay_delivery import (
|
|
11
15
|
AtomicRelayFileTransport,
|
|
@@ -27,6 +31,7 @@ ACCEPTED_EVENT_TYPES = frozenset(
|
|
|
27
31
|
"request_event",
|
|
28
32
|
"probe_event",
|
|
29
33
|
"analytics_event",
|
|
34
|
+
"diagnostic_context",
|
|
30
35
|
}
|
|
31
36
|
)
|
|
32
37
|
|
|
@@ -61,6 +66,7 @@ class BrowserRelayHandler:
|
|
|
61
66
|
service: str | None = None
|
|
62
67
|
environment: str | None = None
|
|
63
68
|
forward_transport: Callable[[Mapping[str, object]], object] | None = None
|
|
69
|
+
evidence_capabilities: object = None
|
|
64
70
|
|
|
65
71
|
def __post_init__(self) -> None:
|
|
66
72
|
self.allowed_origins = [o for o in self.allowed_origins if o]
|
|
@@ -73,8 +79,9 @@ class BrowserRelayHandler:
|
|
|
73
79
|
self._rate_limit_state: dict[str, list[int]] = {}
|
|
74
80
|
self._local_transports: dict[str, AtomicRelayFileTransport] = {}
|
|
75
81
|
self._spool_transports: dict[str, AtomicRelayFileTransport] = {}
|
|
82
|
+
self._evidence = EvidenceDelivery(self.evidence_capabilities)
|
|
76
83
|
self._forwarder = (
|
|
77
|
-
RelayForwardTransport(self.endpoint, self.forward_transport)
|
|
84
|
+
RelayForwardTransport(self.endpoint, self.forward_transport, self._evidence)
|
|
78
85
|
if self.project_mode == "connected" and self.endpoint is not None
|
|
79
86
|
else None
|
|
80
87
|
)
|
|
@@ -87,9 +94,19 @@ class BrowserRelayHandler:
|
|
|
87
94
|
return BrowserRelayResponse(403)
|
|
88
95
|
|
|
89
96
|
response_headers = _cors_headers(source_origin) if source_origin else {}
|
|
97
|
+
discover = headers.get("x-debugbundle-evidence") == "1" or parse_qs(
|
|
98
|
+
urlsplit(str(request.get("url", ""))).query
|
|
99
|
+
).get("debugbundle_evidence") == ["1"]
|
|
90
100
|
|
|
91
101
|
def with_headers(response: BrowserRelayResponse) -> BrowserRelayResponse:
|
|
92
|
-
|
|
102
|
+
body = response.body
|
|
103
|
+
if discover and body is not None:
|
|
104
|
+
body = {**body, "evidence_capabilities": {"version": 1, "features": list(self._evidence.features)}}
|
|
105
|
+
if "errors" in body and indexed_errors:
|
|
106
|
+
body["evidence_errors"] = indexed_errors
|
|
107
|
+
return BrowserRelayResponse(response.status, body, {**response_headers, **response.headers})
|
|
108
|
+
|
|
109
|
+
indexed_errors: list[dict[str, object]] = []
|
|
93
110
|
|
|
94
111
|
if method == "OPTIONS":
|
|
95
112
|
return with_headers(BrowserRelayResponse(204))
|
|
@@ -98,10 +115,16 @@ class BrowserRelayHandler:
|
|
|
98
115
|
return with_headers(BrowserRelayResponse(405))
|
|
99
116
|
|
|
100
117
|
if not _is_supported_content_type(headers.get("content-type")):
|
|
101
|
-
return with_headers(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
118
|
+
return with_headers(
|
|
119
|
+
BrowserRelayResponse(
|
|
120
|
+
400,
|
|
121
|
+
{
|
|
122
|
+
"accepted": 0,
|
|
123
|
+
"rejected": 0,
|
|
124
|
+
"errors": ["Relay requests must use Content-Type: application/json."],
|
|
125
|
+
},
|
|
126
|
+
)
|
|
127
|
+
)
|
|
105
128
|
|
|
106
129
|
body: str = request.get("body", "")
|
|
107
130
|
if len(body.encode("utf-8") if isinstance(body, str) else body) > self.max_body_bytes:
|
|
@@ -114,23 +137,29 @@ class BrowserRelayHandler:
|
|
|
114
137
|
try:
|
|
115
138
|
decoded = json.loads(body)
|
|
116
139
|
except (json.JSONDecodeError, TypeError, ValueError):
|
|
117
|
-
return with_headers(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
140
|
+
return with_headers(
|
|
141
|
+
BrowserRelayResponse(
|
|
142
|
+
400,
|
|
143
|
+
{"accepted": 0, "rejected": 0, "errors": ["Relay request body must be valid JSON."]},
|
|
144
|
+
)
|
|
145
|
+
)
|
|
121
146
|
|
|
122
147
|
if not isinstance(decoded, dict):
|
|
123
|
-
return with_headers(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
148
|
+
return with_headers(
|
|
149
|
+
BrowserRelayResponse(
|
|
150
|
+
400,
|
|
151
|
+
{"accepted": 0, "rejected": 0, "errors": ["Relay request body must be valid JSON."]},
|
|
152
|
+
)
|
|
153
|
+
)
|
|
127
154
|
|
|
128
155
|
batch = decoded.get("batch")
|
|
129
156
|
if not isinstance(batch, list):
|
|
130
|
-
return with_headers(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
157
|
+
return with_headers(
|
|
158
|
+
BrowserRelayResponse(
|
|
159
|
+
400,
|
|
160
|
+
{"accepted": 0, "rejected": 0, "errors": ["Relay request body must include a batch array."]},
|
|
161
|
+
)
|
|
162
|
+
)
|
|
134
163
|
|
|
135
164
|
accepted_events: list[dict[str, Any]] = []
|
|
136
165
|
errors: list[str] = []
|
|
@@ -138,24 +167,31 @@ class BrowserRelayHandler:
|
|
|
138
167
|
for index, candidate in enumerate(batch):
|
|
139
168
|
if not isinstance(candidate, dict):
|
|
140
169
|
errors.append(f"batch[{index}]: Relay events must be objects.")
|
|
170
|
+
indexed_errors.append({"index": index, "reason": "invalid_event"})
|
|
141
171
|
continue
|
|
142
172
|
|
|
143
173
|
event_type = candidate.get("event_type")
|
|
144
174
|
if not isinstance(event_type, str) or event_type not in ACCEPTED_EVENT_TYPES:
|
|
145
175
|
type_label = event_type if isinstance(event_type, str) else "unknown"
|
|
146
176
|
errors.append(f"batch[{index}]: Unsupported browser relay event type {type_label}.")
|
|
177
|
+
indexed_errors.append({"index": index, "reason": "invalid_event"})
|
|
147
178
|
continue
|
|
148
179
|
|
|
149
180
|
sanitized = _sanitize_event(candidate, service_override=self.service, environment_override=self.environment)
|
|
181
|
+
if sanitized is not None and sanitized["event_type"] != "analytics_event":
|
|
182
|
+
sanitized = strip_unsupported(sanitized, self._evidence.features)
|
|
183
|
+
if sanitized is not None and self._forwarder is not None and self.project_token:
|
|
184
|
+
sanitized = self._forwarder.prepare_event(sanitized, self.project_token)
|
|
150
185
|
if sanitized is None:
|
|
151
186
|
errors.append(f"batch[{index}]: Invalid browser relay event payload.")
|
|
187
|
+
indexed_errors.append({"index": index, "reason": "invalid_event"})
|
|
152
188
|
continue
|
|
153
189
|
|
|
154
190
|
accepted_events.append(sanitized)
|
|
155
191
|
|
|
156
192
|
if accepted_events:
|
|
157
193
|
try:
|
|
158
|
-
if not self._deliver_events(accepted_events):
|
|
194
|
+
if not self._deliver_events(accepted_events, discover):
|
|
159
195
|
return with_headers(BrowserRelayResponse(500))
|
|
160
196
|
|
|
161
197
|
if self.on_accept is not None:
|
|
@@ -171,17 +207,21 @@ class BrowserRelayHandler:
|
|
|
171
207
|
return with_headers(BrowserRelayResponse(500))
|
|
172
208
|
|
|
173
209
|
if errors:
|
|
174
|
-
return with_headers(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
210
|
+
return with_headers(
|
|
211
|
+
BrowserRelayResponse(
|
|
212
|
+
202 if discover else 400,
|
|
213
|
+
{"accepted": len(accepted_events), "rejected": len(errors), "errors": errors},
|
|
214
|
+
)
|
|
215
|
+
)
|
|
178
216
|
|
|
179
|
-
return with_headers(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
217
|
+
return with_headers(
|
|
218
|
+
BrowserRelayResponse(
|
|
219
|
+
202,
|
|
220
|
+
{"accepted": len(accepted_events), "rejected": 0, "errors": []},
|
|
221
|
+
)
|
|
222
|
+
)
|
|
183
223
|
|
|
184
|
-
def _deliver_events(self, accepted_events: list[dict[str, Any]]) -> bool:
|
|
224
|
+
def _deliver_events(self, accepted_events: list[dict[str, Any]], discover: bool = False) -> bool:
|
|
185
225
|
if self.project_mode is None:
|
|
186
226
|
return True
|
|
187
227
|
|
|
@@ -214,20 +254,22 @@ class BrowserRelayHandler:
|
|
|
214
254
|
if spool_write_result.status_code != 202:
|
|
215
255
|
return False
|
|
216
256
|
|
|
217
|
-
configured,
|
|
218
|
-
if
|
|
257
|
+
configured, _succeeded, complete = self._forward_connected_events(accepted_events, discover)
|
|
258
|
+
if complete and spool_write_result.written_file_path is not None:
|
|
219
259
|
mark_spool_file_delivered(spool_write_result.written_file_path)
|
|
220
260
|
|
|
221
261
|
return True if configured or spool_write_result.written_file_path is not None else False
|
|
222
262
|
|
|
223
|
-
configured, succeeded = self._forward_connected_events(accepted_events)
|
|
263
|
+
configured, succeeded, _complete = self._forward_connected_events(accepted_events, discover)
|
|
224
264
|
return configured and succeeded
|
|
225
265
|
|
|
226
|
-
def _forward_connected_events(
|
|
266
|
+
def _forward_connected_events(
|
|
267
|
+
self, accepted_events: list[dict[str, Any]], discover: bool = False
|
|
268
|
+
) -> tuple[bool, bool, bool]:
|
|
227
269
|
if self._forwarder is None or not self.project_token:
|
|
228
|
-
return (False, False)
|
|
270
|
+
return (False, False, False)
|
|
229
271
|
|
|
230
|
-
return self._forwarder.
|
|
272
|
+
return self._forwarder.send_verified(self.project_token, accepted_events, discover)
|
|
231
273
|
|
|
232
274
|
def _is_origin_allowed(self, headers: dict[str, str]) -> bool:
|
|
233
275
|
origin = _source_origin(headers)
|
|
@@ -277,7 +319,7 @@ def _cors_headers(origin: str) -> dict[str, str]:
|
|
|
277
319
|
return {
|
|
278
320
|
"access-control-allow-origin": origin,
|
|
279
321
|
"access-control-allow-methods": "POST, OPTIONS",
|
|
280
|
-
"access-control-allow-headers": "content-type",
|
|
322
|
+
"access-control-allow-headers": "content-type, x-debugbundle-evidence",
|
|
281
323
|
"access-control-max-age": "600",
|
|
282
324
|
"vary": "Origin",
|
|
283
325
|
}
|
|
@@ -393,11 +435,26 @@ def _sanitize_event(
|
|
|
393
435
|
if normalized_correlation:
|
|
394
436
|
sanitized["correlation"] = normalized_correlation
|
|
395
437
|
|
|
438
|
+
if isinstance(event.get("context"), dict):
|
|
439
|
+
sanitized["context"] = event["context"]
|
|
440
|
+
evidence = event_type != "analytics_event" and (
|
|
441
|
+
event_type == "diagnostic_context"
|
|
442
|
+
or any(key in payload for key in ("exception_evidence", "execution", "resource_observations"))
|
|
443
|
+
)
|
|
444
|
+
if evidence and not _is_valid_event(sanitized):
|
|
445
|
+
return None
|
|
446
|
+
|
|
396
447
|
try:
|
|
397
448
|
if not has_safe_event_identity(sanitized):
|
|
398
449
|
return None
|
|
399
450
|
sanitized["payload"] = sanitize_telemetry(sanitized["payload"])
|
|
400
451
|
sanitized["service"] = sanitize_telemetry(sanitized["service"])
|
|
452
|
+
if "context" in sanitized:
|
|
453
|
+
sanitized["context"] = sanitize_telemetry(sanitized["context"])
|
|
401
454
|
except UnsafeTelemetry:
|
|
402
|
-
|
|
455
|
+
fallback = without_optional(sanitized) if evidence else None
|
|
456
|
+
return _sanitize_event(fallback, service_override, environment_override) if fallback is not None else None
|
|
457
|
+
if evidence and (not _is_valid_event(sanitized) or not validate_evidence(sanitized)):
|
|
458
|
+
fallback = without_optional(sanitized)
|
|
459
|
+
return _sanitize_event(fallback, service_override, environment_override) if fallback is not None else None
|
|
403
460
|
return sanitized
|
debugbundle/relay_delivery.py
CHANGED
|
@@ -4,12 +4,17 @@ import json
|
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
6
|
import secrets
|
|
7
|
+
import stat
|
|
7
8
|
import threading
|
|
8
9
|
import time
|
|
9
10
|
from collections.abc import Callable, Mapping
|
|
10
11
|
from dataclasses import dataclass
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
14
|
+
from .acknowledgement import decide_acknowledgement
|
|
15
|
+
from .evidence_delivery import EvidenceDelivery
|
|
16
|
+
from .evidence_wire import FEATURES, strip_unsupported
|
|
17
|
+
from .ingestion_wire import MAX_EVENT_BYTES, chunks, event_bytes, fit_event
|
|
13
18
|
from .transport import HttpTransport, coerce_transport_response
|
|
14
19
|
|
|
15
20
|
LOCAL_EVENTS_DIRECTORY_MODE = 0o700
|
|
@@ -38,8 +43,11 @@ def attach_project_token(events: list[dict[str, Any]], project_token: str) -> li
|
|
|
38
43
|
|
|
39
44
|
def mark_spool_file_delivered(written_file_path: str) -> None:
|
|
40
45
|
try:
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
if not stat.S_ISREG(os.lstat(written_file_path).st_mode):
|
|
47
|
+
return
|
|
48
|
+
marker = f"{written_file_path}{RELAY_SPOOL_DELIVERED_MARKER_SUFFIX}"
|
|
49
|
+
fd = os.open(marker, os.O_CREAT | os.O_EXCL | os.O_WRONLY | OPTIONAL_NOFOLLOW_FLAG, LOCAL_EVENT_FILE_MODE)
|
|
50
|
+
os.close(fd)
|
|
43
51
|
except OSError:
|
|
44
52
|
# Durable acceptance already happened at the spool write; marker creation is maintenance metadata only.
|
|
45
53
|
return
|
|
@@ -79,26 +87,109 @@ class AtomicRelayFileTransport:
|
|
|
79
87
|
|
|
80
88
|
|
|
81
89
|
class RelayForwardTransport:
|
|
82
|
-
def __init__(
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
endpoint: str,
|
|
93
|
+
transport: Callable[[Mapping[str, object]], object] | None = None,
|
|
94
|
+
evidence: EvidenceDelivery | None = None,
|
|
95
|
+
) -> None:
|
|
83
96
|
self._transport = transport or HttpTransport(endpoint)
|
|
84
|
-
|
|
85
|
-
|
|
97
|
+
self._evidence = evidence or EvidenceDelivery()
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def prepare_event(event: dict[str, Any], project_token: str) -> dict[str, Any] | None:
|
|
101
|
+
owned = dict(event, project_token=project_token)
|
|
102
|
+
if event.get("event_type") == "analytics_event":
|
|
103
|
+
reduced = owned if event_bytes(owned) <= MAX_EVENT_BYTES else None
|
|
104
|
+
else:
|
|
105
|
+
reduced = fit_event(owned)
|
|
106
|
+
return {key: value for key, value in reduced.items() if key != "project_token"} if reduced else None
|
|
107
|
+
|
|
108
|
+
def send(self, project_token: str, events: list[dict[str, Any]], discover: bool = False) -> tuple[bool, bool]:
|
|
109
|
+
configured, success, _complete = self.send_verified(project_token, events, discover)
|
|
110
|
+
return configured, success
|
|
111
|
+
|
|
112
|
+
def send_verified(
|
|
113
|
+
self, project_token: str, events: list[dict[str, Any]], discover: bool = False
|
|
114
|
+
) -> tuple[bool, bool, bool]:
|
|
86
115
|
if not project_token:
|
|
87
|
-
return
|
|
116
|
+
return False, False, False
|
|
117
|
+
deadline = time.monotonic() + 5.0
|
|
118
|
+
try:
|
|
119
|
+
complete = True
|
|
120
|
+
# Start with the neutral intersection, so a previously unknown path
|
|
121
|
+
# can learn proof from this batch's canonical existing ACK traffic.
|
|
122
|
+
features = list(FEATURES)
|
|
123
|
+
for original in chunks(attach_project_token(events, project_token)):
|
|
124
|
+
if time.monotonic() >= deadline:
|
|
125
|
+
return True, False, False
|
|
126
|
+
batch: list[dict[str, Any]] = []
|
|
127
|
+
for event in original:
|
|
128
|
+
selected = (
|
|
129
|
+
strip_unsupported(event, self._evidence.features)
|
|
130
|
+
if event.get("event_type") != "analytics_event"
|
|
131
|
+
else event
|
|
132
|
+
)
|
|
133
|
+
if selected is None:
|
|
134
|
+
return True, False, False
|
|
135
|
+
batch.append(selected)
|
|
136
|
+
success, canonical = self._send_chunk(project_token, batch, discover, deadline)
|
|
137
|
+
features = [feature for feature in features if feature in self._evidence.features]
|
|
138
|
+
self._evidence.features = list(features)
|
|
139
|
+
if not success or time.monotonic() >= deadline:
|
|
140
|
+
return True, False, False
|
|
141
|
+
complete = complete and canonical
|
|
142
|
+
return True, True, complete
|
|
143
|
+
except Exception:
|
|
144
|
+
return True, False, False
|
|
88
145
|
|
|
146
|
+
def _send_chunk(
|
|
147
|
+
self, project_token: str, events: list[dict[str, Any]], discover: bool, deadline: float
|
|
148
|
+
) -> tuple[bool, bool]:
|
|
89
149
|
try:
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
150
|
+
|
|
151
|
+
def send(batch: list[dict[str, Any]]):
|
|
152
|
+
if time.monotonic() >= deadline:
|
|
153
|
+
raise TimeoutError("relay forwarding deadline exhausted")
|
|
154
|
+
request = {"project_token": project_token, "events": batch}
|
|
155
|
+
response = (
|
|
156
|
+
self._transport.send_until(request, deadline)
|
|
157
|
+
if isinstance(self._transport, HttpTransport)
|
|
158
|
+
else self._transport(request)
|
|
96
159
|
)
|
|
97
|
-
|
|
160
|
+
return coerce_transport_response(response)
|
|
161
|
+
|
|
162
|
+
response = send(events)
|
|
163
|
+
if not 200 <= response.status_code < 300:
|
|
164
|
+
return False, False
|
|
165
|
+
required = discover or isinstance(self._transport, HttpTransport)
|
|
166
|
+
ack = decide_acknowledgement(response.body, len(events), required)
|
|
167
|
+
lost = self._evidence.observe(response.body, required, ack.kind == "acknowledged")
|
|
168
|
+
if ack.kind == "legacy":
|
|
169
|
+
return True, False
|
|
170
|
+
if ack.kind != "acknowledged":
|
|
171
|
+
return False, False
|
|
172
|
+
if ack.accepted == len(events):
|
|
173
|
+
return True, True
|
|
174
|
+
retries = []
|
|
175
|
+
if lost:
|
|
176
|
+
for index, reason in ack.terminal_errors:
|
|
177
|
+
original = events[index]
|
|
178
|
+
if reason == "invalid_event" and original.get("event_type") == "frontend_exception":
|
|
179
|
+
reduced = strip_unsupported(original, self._evidence.features)
|
|
180
|
+
if reduced is not None and reduced != original:
|
|
181
|
+
retries.append(reduced)
|
|
182
|
+
if not retries:
|
|
183
|
+
return False, False
|
|
184
|
+
response = send(retries)
|
|
185
|
+
if not 200 <= response.status_code < 300:
|
|
186
|
+
return False, False
|
|
187
|
+
retry_ack = decide_acknowledgement(response.body, len(retries), True)
|
|
188
|
+
self._evidence.observe(response.body, required, retry_ack.kind == "acknowledged")
|
|
189
|
+
complete = retry_ack.kind == "acknowledged" and ack.accepted + retry_ack.accepted == len(events)
|
|
190
|
+
return complete, complete
|
|
98
191
|
except Exception:
|
|
99
|
-
return
|
|
100
|
-
|
|
101
|
-
return (True, 200 <= response.status_code < 300)
|
|
192
|
+
return False, False
|
|
102
193
|
|
|
103
194
|
|
|
104
195
|
def _sanitize_service_name(service_name: str) -> str:
|
|
@@ -135,4 +226,4 @@ def _cleanup_temp_files(events_dir: str) -> None:
|
|
|
135
226
|
except OSError:
|
|
136
227
|
continue
|
|
137
228
|
except OSError:
|
|
138
|
-
return
|
|
229
|
+
return
|