debugbundle-python 2.0.1__py3-none-any.whl → 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. debugbundle/__init__.py +16 -0
  2. debugbundle/before_send.py +35 -191
  3. debugbundle/canonical_event.py +202 -0
  4. debugbundle/config.py +6 -0
  5. debugbundle/core.py +76 -318
  6. debugbundle/delivery_hooks.py +14 -0
  7. debugbundle/diagnostic_budget.py +191 -0
  8. debugbundle/event-envelope.schema.json +4904 -0
  9. debugbundle/evidence_delivery.py +115 -0
  10. debugbundle/evidence_http.py +45 -0
  11. debugbundle/evidence_json.py +68 -0
  12. debugbundle/evidence_wire.py +409 -0
  13. debugbundle/exception_capture.py +92 -0
  14. debugbundle/exception_evidence.py +199 -0
  15. debugbundle/exception_snapshot.py +1 -1
  16. debugbundle/flush_delivery.py +203 -0
  17. debugbundle/ingestion_wire.py +75 -0
  18. debugbundle/integrations/relay_django.py +4 -1
  19. debugbundle/integrations/relay_fastapi.py +10 -2
  20. debugbundle/integrations/relay_flask.py +4 -1
  21. debugbundle/operation_context.py +121 -0
  22. debugbundle/process_support.py +7 -0
  23. debugbundle/queue_admission.py +42 -2
  24. debugbundle/recovery_capture.py +43 -0
  25. debugbundle/relay.py +93 -36
  26. debugbundle/relay_delivery.py +108 -17
  27. debugbundle/remote_config_client.py +81 -0
  28. debugbundle/scope_ownership.py +99 -0
  29. debugbundle/transport.py +20 -14
  30. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/METADATA +116 -1
  31. debugbundle_python-2.1.0.dist-info/RECORD +51 -0
  32. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/WHEEL +1 -1
  33. debugbundle_python-2.0.1.dist-info/RECORD +0 -36
  34. {debugbundle_python-2.0.1.dist-info → debugbundle_python-2.1.0.dist-info}/licenses/LICENSE +0 -0
  35. {debugbundle_python-2.0.1.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
@@ -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
@@ -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 (event.get("event_type") == "request_event" and type(payload.get("response_status")) is int
18
- and payload["response_status"] >= 500)
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
- return BrowserRelayResponse(response.status, response.body, {**response_headers, **response.headers})
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(BrowserRelayResponse(
102
- 400,
103
- {"accepted": 0, "rejected": 0, "errors": ["Relay requests must use Content-Type: application/json."]},
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(BrowserRelayResponse(
118
- 400,
119
- {"accepted": 0, "rejected": 0, "errors": ["Relay request body must be valid JSON."]},
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(BrowserRelayResponse(
124
- 400,
125
- {"accepted": 0, "rejected": 0, "errors": ["Relay request body must be valid JSON."]},
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(BrowserRelayResponse(
131
- 400,
132
- {"accepted": 0, "rejected": 0, "errors": ["Relay request body must include a batch array."]},
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(BrowserRelayResponse(
175
- 400,
176
- {"accepted": len(accepted_events), "rejected": len(errors), "errors": errors},
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(BrowserRelayResponse(
180
- 202,
181
- {"accepted": len(accepted_events), "rejected": 0, "errors": []},
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, succeeded = self._forward_connected_events(accepted_events)
218
- if succeeded and spool_write_result.written_file_path is not None:
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(self, accepted_events: list[dict[str, Any]]) -> tuple[bool, bool]:
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.send(self.project_token, accepted_events)
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
- return None
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
@@ -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
- with open(f"{written_file_path}{RELAY_SPOOL_DELIVERED_MARKER_SUFFIX}", "w", encoding="utf-8"):
42
- pass
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__(self, endpoint: str, transport: Callable[[Mapping[str, object]], object] | None = None) -> None:
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
- def send(self, project_token: str, events: list[dict[str, Any]]) -> tuple[bool, bool]:
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 (False, False)
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
- response = coerce_transport_response(
91
- self._transport(
92
- {
93
- "project_token": project_token,
94
- "events": attach_project_token(events, project_token),
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 (True, False)
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