debugbundle-python 0.1.5__tar.gz → 0.1.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {debugbundle_python-0.1.5/src/debugbundle_python.egg-info → debugbundle_python-0.1.6}/PKG-INFO +20 -4
  2. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/README.md +18 -2
  3. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/pyproject.toml +2 -2
  4. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/relay_django.py +18 -0
  5. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/relay_fastapi.py +18 -0
  6. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/relay_flask.py +18 -0
  7. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/relay.py +122 -17
  8. debugbundle_python-0.1.6/src/debugbundle/relay_delivery.py +138 -0
  9. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6/src/debugbundle_python.egg-info}/PKG-INFO +20 -4
  10. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle_python.egg-info/SOURCES.txt +2 -0
  11. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_framework_integrations.py +71 -1
  12. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_relay.py +122 -15
  13. debugbundle_python-0.1.6/tests/test_relay_delivery.py +111 -0
  14. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/LICENSE +0 -0
  15. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/setup.cfg +0 -0
  16. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/__init__.py +0 -0
  17. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/config.py +0 -0
  18. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/core.py +0 -0
  19. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/__init__.py +0 -0
  20. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/common.py +0 -0
  21. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/django.py +0 -0
  22. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/fastapi.py +0 -0
  23. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/flask.py +0 -0
  24. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/logger_integrations.py +0 -0
  25. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/py.typed +0 -0
  26. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/redaction.py +0 -0
  27. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/suppression.py +0 -0
  28. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/transport.py +0 -0
  29. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/trigger_token.py +0 -0
  30. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle_python.egg-info/dependency_links.txt +0 -0
  31. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle_python.egg-info/requires.txt +0 -0
  32. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle_python.egg-info/top_level.txt +0 -0
  33. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_contracts.py +0 -0
  34. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_hooks.py +0 -0
  35. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_http_integration.py +0 -0
  36. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_remote_config.py +0 -0
  37. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_repository_metadata.py +0 -0
  38. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_schema_validation.py +0 -0
  39. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_sdk.py +0 -0
  40. {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_trigger_token.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: DebugBundle SDK for Python
5
5
  Author: DebugBundle
6
6
  License-Expression: AGPL-3.0-only
7
- Project-URL: Homepage, https://debugbundle.com/docs/sdk-python
7
+ Project-URL: Homepage, https://debugbundle.com/docs/sdks/python
8
8
  Project-URL: Repository, https://github.com/debugbundle/debugbundle-python
9
9
  Project-URL: Issues, https://github.com/debugbundle/debugbundle-python/issues
10
10
  Keywords: debugbundle,debugging,ai-agent,error-tracking
@@ -59,7 +59,7 @@ debugbundle.flush()
59
59
 
60
60
  ## Status
61
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, 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, and safe backend runtime process facts on exception payloads without reading environment variables.
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, 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, full browser relay handler parity with Django/Flask/FastAPI helpers plus local-only and connected delivery modes, and safe backend runtime process facts on exception payloads without reading environment variables.
63
63
 
64
64
  ## Runtime Context
65
65
 
@@ -77,9 +77,25 @@ Backend exception events now include safe runtime process facts when the host ex
77
77
 
78
78
  The SDK does not read or emit environment variables in this runtime block.
79
79
 
80
+ ## Browser Relay
81
+
82
+ The Python SDK includes a framework-agnostic `BrowserRelayHandler` plus framework helpers for the contract-required `POST /debugbundle/browser` endpoint in Python servers that also load `@debugbundle/sdk-browser`.
83
+
84
+ - `create_django_relay_view()` returns a Django view for the relay route.
85
+ - `create_flask_relay_handler()` registers the relay route on a Flask app.
86
+ - `create_fastapi_relay_handler()` registers the relay route on a FastAPI app.
87
+
88
+ The relay handler enforces same-origin or configured allowed origins, requires `Content-Type: application/json`, accepts the canonical `batch` body shape only, rejects bodies larger than `256 KB`, applies per-IP rate limiting, accepts only supported browser event types, strips trust-sensitive headers and fields, forces `sdk_name` to `@debugbundle/sdk-browser`, and preserves browser correlation fields (`request_id`, `trace_id`, `session_id`, and `user_id_hash`) when they are strings or `null`.
89
+
90
+ Delivery behavior matches the shared relay contract across the shipped server SDKs:
91
+
92
+ - `project_mode="local-only"` writes accepted browser events to local event files for CLI processing.
93
+ - `project_mode="connected"` with the default `durable_write=True` writes a durable relay spool record and then forwards to the ingestion API with the server-side project token.
94
+ - `project_mode="connected"` with `durable_write=False` uses the lower-latency forward-only path.
95
+
80
96
  ## Docs
81
97
 
82
- https://debugbundle.com/docs/sdk-python
98
+ https://debugbundle.com/docs/sdks/python
83
99
 
84
100
  ## License
85
101
 
@@ -20,7 +20,7 @@ debugbundle.flush()
20
20
 
21
21
  ## Status
22
22
 
23
- 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, 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, and safe backend runtime process facts on exception payloads without reading environment variables.
23
+ 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, 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, full browser relay handler parity with Django/Flask/FastAPI helpers plus local-only and connected delivery modes, and safe backend runtime process facts on exception payloads without reading environment variables.
24
24
 
25
25
  ## Runtime Context
26
26
 
@@ -38,9 +38,25 @@ Backend exception events now include safe runtime process facts when the host ex
38
38
 
39
39
  The SDK does not read or emit environment variables in this runtime block.
40
40
 
41
+ ## Browser Relay
42
+
43
+ The Python SDK includes a framework-agnostic `BrowserRelayHandler` plus framework helpers for the contract-required `POST /debugbundle/browser` endpoint in Python servers that also load `@debugbundle/sdk-browser`.
44
+
45
+ - `create_django_relay_view()` returns a Django view for the relay route.
46
+ - `create_flask_relay_handler()` registers the relay route on a Flask app.
47
+ - `create_fastapi_relay_handler()` registers the relay route on a FastAPI app.
48
+
49
+ The relay handler enforces same-origin or configured allowed origins, requires `Content-Type: application/json`, accepts the canonical `batch` body shape only, rejects bodies larger than `256 KB`, applies per-IP rate limiting, accepts only supported browser event types, strips trust-sensitive headers and fields, forces `sdk_name` to `@debugbundle/sdk-browser`, and preserves browser correlation fields (`request_id`, `trace_id`, `session_id`, and `user_id_hash`) when they are strings or `null`.
50
+
51
+ Delivery behavior matches the shared relay contract across the shipped server SDKs:
52
+
53
+ - `project_mode="local-only"` writes accepted browser events to local event files for CLI processing.
54
+ - `project_mode="connected"` with the default `durable_write=True` writes a durable relay spool record and then forwards to the ingestion API with the server-side project token.
55
+ - `project_mode="connected"` with `durable_write=False` uses the lower-latency forward-only path.
56
+
41
57
  ## Docs
42
58
 
43
- https://debugbundle.com/docs/sdk-python
59
+ https://debugbundle.com/docs/sdks/python
44
60
 
45
61
  ## License
46
62
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "debugbundle-python"
7
- version = "0.1.5"
7
+ version = "0.1.6"
8
8
  description = "DebugBundle SDK for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -44,7 +44,7 @@ dev = [
44
44
  ]
45
45
 
46
46
  [project.urls]
47
- Homepage = "https://debugbundle.com/docs/sdk-python"
47
+ Homepage = "https://debugbundle.com/docs/sdks/python"
48
48
  Repository = "https://github.com/debugbundle/debugbundle-python"
49
49
  Issues = "https://github.com/debugbundle/debugbundle-python/issues"
50
50
 
@@ -11,12 +11,30 @@ def create_django_relay_view(
11
11
  max_body_bytes: int = 262_144,
12
12
  rate_limit_per_minute: int = 60,
13
13
  on_accept: Any = None,
14
+ project_mode: str | None = None,
15
+ project_token: str | None = None,
16
+ endpoint: str | None = None,
17
+ local_events_dir: str | None = None,
18
+ spool_dir: str | None = None,
19
+ durable_write: bool = True,
20
+ service: str | None = None,
21
+ environment: str | None = None,
22
+ forward_transport: Any = None,
14
23
  ) -> Any:
15
24
  handler = BrowserRelayHandler(
16
25
  allowed_origins=allowed_origins or [],
17
26
  max_body_bytes=max_body_bytes,
18
27
  rate_limit_per_minute=rate_limit_per_minute,
19
28
  on_accept=on_accept,
29
+ project_mode=project_mode,
30
+ project_token=project_token,
31
+ endpoint=endpoint,
32
+ local_events_dir=local_events_dir,
33
+ spool_dir=spool_dir,
34
+ durable_write=durable_write,
35
+ service=service,
36
+ environment=environment,
37
+ forward_transport=forward_transport,
20
38
  )
21
39
 
22
40
  def view(request: Any) -> Any:
@@ -14,6 +14,15 @@ def create_fastapi_relay_handler(
14
14
  max_body_bytes: int = 262_144,
15
15
  rate_limit_per_minute: int = 60,
16
16
  on_accept: Any = None,
17
+ project_mode: str | None = None,
18
+ project_token: str | None = None,
19
+ endpoint: str | None = None,
20
+ local_events_dir: str | None = None,
21
+ spool_dir: str | None = None,
22
+ durable_write: bool = True,
23
+ service: str | None = None,
24
+ environment: str | None = None,
25
+ forward_transport: Any = None,
17
26
  route_path: str = "/debugbundle/browser",
18
27
  ) -> Any:
19
28
  handler = BrowserRelayHandler(
@@ -21,6 +30,15 @@ def create_fastapi_relay_handler(
21
30
  max_body_bytes=max_body_bytes,
22
31
  rate_limit_per_minute=rate_limit_per_minute,
23
32
  on_accept=on_accept,
33
+ project_mode=project_mode,
34
+ project_token=project_token,
35
+ endpoint=endpoint,
36
+ local_events_dir=local_events_dir,
37
+ spool_dir=spool_dir,
38
+ durable_write=durable_write,
39
+ service=service,
40
+ environment=environment,
41
+ forward_transport=forward_transport,
24
42
  )
25
43
 
26
44
  def register(app: Any) -> None:
@@ -11,6 +11,15 @@ def create_flask_relay_handler(
11
11
  max_body_bytes: int = 262_144,
12
12
  rate_limit_per_minute: int = 60,
13
13
  on_accept: Any = None,
14
+ project_mode: str | None = None,
15
+ project_token: str | None = None,
16
+ endpoint: str | None = None,
17
+ local_events_dir: str | None = None,
18
+ spool_dir: str | None = None,
19
+ durable_write: bool = True,
20
+ service: str | None = None,
21
+ environment: str | None = None,
22
+ forward_transport: Any = None,
14
23
  route_path: str = "/debugbundle/browser",
15
24
  ) -> Any:
16
25
  handler = BrowserRelayHandler(
@@ -18,6 +27,15 @@ def create_flask_relay_handler(
18
27
  max_body_bytes=max_body_bytes,
19
28
  rate_limit_per_minute=rate_limit_per_minute,
20
29
  on_accept=on_accept,
30
+ project_mode=project_mode,
31
+ project_token=project_token,
32
+ endpoint=endpoint,
33
+ local_events_dir=local_events_dir,
34
+ spool_dir=spool_dir,
35
+ durable_write=durable_write,
36
+ service=service,
37
+ environment=environment,
38
+ forward_transport=forward_transport,
21
39
  )
22
40
 
23
41
  def register(app: Any) -> None:
@@ -2,10 +2,18 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import time
5
- from collections.abc import Callable
5
+ from collections.abc import Callable, Mapping
6
6
  from dataclasses import dataclass, field
7
7
  from typing import Any
8
8
 
9
+ from .relay_delivery import (
10
+ AtomicRelayFileTransport,
11
+ RelayForwardTransport,
12
+ mark_spool_file_delivered,
13
+ resolve_default_local_events_dir,
14
+ resolve_default_relay_spool_dir,
15
+ )
16
+
9
17
  DEFAULT_MAX_BODY_BYTES = 262_144
10
18
  DEFAULT_RATE_LIMIT_PER_MINUTE = 60
11
19
  BROWSER_SDK_NAME = "@debugbundle/sdk-browser"
@@ -41,12 +49,32 @@ class BrowserRelayHandler:
41
49
  max_body_bytes: int = DEFAULT_MAX_BODY_BYTES
42
50
  rate_limit_per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE
43
51
  on_accept: Callable[[BrowserRelayAcceptedBatch], None] | None = None
52
+ project_mode: str | None = None
53
+ project_token: str | None = None
54
+ endpoint: str | None = None
55
+ local_events_dir: str | None = None
56
+ spool_dir: str | None = None
57
+ durable_write: bool = True
58
+ service: str | None = None
59
+ environment: str | None = None
60
+ forward_transport: Callable[[Mapping[str, object]], object] | None = None
44
61
 
45
62
  def __post_init__(self) -> None:
46
63
  self.allowed_origins = [o for o in self.allowed_origins if o]
47
64
  self.max_body_bytes = max(1, self.max_body_bytes)
48
65
  self.rate_limit_per_minute = max(1, self.rate_limit_per_minute)
66
+ normalized_project_mode = (self.project_mode or "").strip().lower()
67
+ self.project_mode = normalized_project_mode or None
68
+ if self.project_mode not in {None, "connected", "local-only"}:
69
+ self.project_mode = None
49
70
  self._rate_limit_state: dict[str, list[int]] = {}
71
+ self._local_transports: dict[str, AtomicRelayFileTransport] = {}
72
+ self._spool_transports: dict[str, AtomicRelayFileTransport] = {}
73
+ self._forwarder = (
74
+ RelayForwardTransport(self.endpoint, self.forward_transport)
75
+ if self.project_mode == "connected" and self.endpoint is not None
76
+ else None
77
+ )
50
78
 
51
79
  def handle(self, request: dict[str, Any]) -> BrowserRelayResponse:
52
80
  method = str(request.get("method", "POST")).upper()
@@ -106,22 +134,29 @@ class BrowserRelayHandler:
106
134
  errors.append(f"batch[{index}]: Unsupported browser relay event type {type_label}.")
107
135
  continue
108
136
 
109
- sanitized = _sanitize_event(candidate)
137
+ sanitized = _sanitize_event(candidate, service_override=self.service, environment_override=self.environment)
110
138
  if sanitized is None:
111
139
  errors.append(f"batch[{index}]: Invalid browser relay event payload.")
112
140
  continue
113
141
 
114
142
  accepted_events.append(sanitized)
115
143
 
116
- if accepted_events and self.on_accept is not None:
117
- self.on_accept(
118
- BrowserRelayAcceptedBatch(
119
- events=accepted_events,
120
- headers=_strip_sensitive_headers(headers),
121
- ip_address=ip_address,
122
- received_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
123
- )
124
- )
144
+ if accepted_events:
145
+ try:
146
+ if not self._deliver_events(accepted_events):
147
+ return BrowserRelayResponse(500)
148
+
149
+ if self.on_accept is not None:
150
+ self.on_accept(
151
+ BrowserRelayAcceptedBatch(
152
+ events=accepted_events,
153
+ headers=_strip_sensitive_headers(headers),
154
+ ip_address=ip_address,
155
+ received_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
156
+ )
157
+ )
158
+ except Exception:
159
+ return BrowserRelayResponse(500)
125
160
 
126
161
  if errors:
127
162
  return BrowserRelayResponse(
@@ -134,6 +169,54 @@ class BrowserRelayHandler:
134
169
  {"accepted": len(accepted_events), "rejected": 0, "errors": []},
135
170
  )
136
171
 
172
+ def _deliver_events(self, accepted_events: list[dict[str, Any]]) -> bool:
173
+ if self.project_mode is None:
174
+ return True
175
+
176
+ service_name = self.service or str(accepted_events[0]["service"]["name"])
177
+
178
+ if self.project_mode == "local-only":
179
+ local_transport = self._local_transports.get(service_name)
180
+ if local_transport is None:
181
+ local_transport = AtomicRelayFileTransport(
182
+ self.local_events_dir or resolve_default_local_events_dir(),
183
+ service_name,
184
+ )
185
+ self._local_transports[service_name] = local_transport
186
+
187
+ return local_transport.write(accepted_events).status_code == 202
188
+
189
+ if self.project_mode != "connected":
190
+ return True
191
+
192
+ if self.durable_write:
193
+ spool_transport = self._spool_transports.get(service_name)
194
+ if spool_transport is None:
195
+ spool_transport = AtomicRelayFileTransport(
196
+ self.spool_dir or resolve_default_relay_spool_dir(),
197
+ service_name,
198
+ )
199
+ self._spool_transports[service_name] = spool_transport
200
+
201
+ spool_write_result = spool_transport.write(accepted_events)
202
+ if spool_write_result.status_code != 202:
203
+ return False
204
+
205
+ configured, succeeded = self._forward_connected_events(accepted_events)
206
+ if succeeded and spool_write_result.written_file_path is not None:
207
+ mark_spool_file_delivered(spool_write_result.written_file_path)
208
+
209
+ return True if configured or spool_write_result.written_file_path is not None else False
210
+
211
+ configured, succeeded = self._forward_connected_events(accepted_events)
212
+ return configured and succeeded
213
+
214
+ def _forward_connected_events(self, accepted_events: list[dict[str, Any]]) -> tuple[bool, bool]:
215
+ if self._forwarder is None or not self.project_token:
216
+ return (False, False)
217
+
218
+ return self._forwarder.send(self.project_token, accepted_events)
219
+
137
220
  def _is_origin_allowed(self, headers: dict[str, str]) -> bool:
138
221
  origin = _source_origin(headers)
139
222
  if origin is None:
@@ -210,7 +293,11 @@ def _strip_sensitive_headers(headers: dict[str, str]) -> dict[str, str]:
210
293
  return sanitized
211
294
 
212
295
 
213
- def _sanitize_event(event: dict[str, Any]) -> dict[str, Any] | None:
296
+ def _sanitize_event(
297
+ event: dict[str, Any],
298
+ service_override: str | None = None,
299
+ environment_override: str | None = None,
300
+ ) -> dict[str, Any] | None:
214
301
  schema_version = event.get("schema_version")
215
302
  event_id = event.get("event_id")
216
303
  event_type = event.get("event_type")
@@ -239,6 +326,11 @@ def _sanitize_event(event: dict[str, Any]) -> dict[str, Any] | None:
239
326
  if not isinstance(service_name, str) or not service_name or not isinstance(environment, str) or not environment:
240
327
  return None
241
328
 
329
+ normalized_service_name = service_override or service_name
330
+ normalized_environment = environment_override or environment
331
+ if not normalized_service_name or not normalized_environment:
332
+ return None
333
+
242
334
  sanitized: dict[str, Any] = {
243
335
  "schema_version": schema_version,
244
336
  "event_id": event_id,
@@ -247,16 +339,29 @@ def _sanitize_event(event: dict[str, Any]) -> dict[str, Any] | None:
247
339
  "sdk_name": BROWSER_SDK_NAME,
248
340
  "sdk_version": sdk_version,
249
341
  "service": {
250
- "name": service_name,
251
- "environment": environment,
342
+ "name": normalized_service_name,
343
+ "environment": normalized_environment,
252
344
  },
253
345
  "payload": payload,
254
346
  }
255
347
 
348
+ runtime = service.get("runtime")
349
+ if isinstance(runtime, str) or runtime is None:
350
+ sanitized["service"]["runtime"] = runtime
351
+
352
+ framework = service.get("framework")
353
+ if isinstance(framework, str) or framework is None:
354
+ sanitized["service"]["framework"] = framework
355
+
256
356
  correlation = event.get("correlation")
257
357
  if isinstance(correlation, dict):
258
- trace_id = correlation.get("trace_id")
259
- if isinstance(trace_id, str) or trace_id is None:
260
- sanitized["correlation"] = {"trace_id": trace_id}
358
+ normalized_correlation: dict[str, Any] = {}
359
+ for key in ("request_id", "trace_id", "session_id", "user_id_hash"):
360
+ value = correlation.get(key)
361
+ if isinstance(value, str) or value is None:
362
+ normalized_correlation[key] = value
363
+
364
+ if normalized_correlation:
365
+ sanitized["correlation"] = normalized_correlation
261
366
 
262
367
  return sanitized
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import secrets
7
+ import threading
8
+ import time
9
+ from collections.abc import Callable, Mapping
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+
13
+ from .transport import HttpTransport, coerce_transport_response
14
+
15
+ LOCAL_EVENTS_DIRECTORY_MODE = 0o700
16
+ LOCAL_EVENT_FILE_MODE = 0o600
17
+ RELAY_SPOOL_DELIVERED_MARKER_SUFFIX = ".delivered"
18
+ OPTIONAL_NOFOLLOW_FLAG = getattr(os, "O_NOFOLLOW", 0)
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class RelayWriteResult:
23
+ status_code: int
24
+ written_file_path: str | None = None
25
+
26
+
27
+ def resolve_default_local_events_dir(cwd: str | None = None) -> str:
28
+ return os.path.join(cwd or os.getcwd(), ".debugbundle", "local", "events")
29
+
30
+
31
+ def resolve_default_relay_spool_dir(cwd: str | None = None) -> str:
32
+ return os.path.join(cwd or os.getcwd(), ".debugbundle", "local", "browser-relay-spool")
33
+
34
+
35
+ def attach_project_token(events: list[dict[str, Any]], project_token: str) -> list[dict[str, Any]]:
36
+ return [{**event, "project_token": project_token} for event in events]
37
+
38
+
39
+ def mark_spool_file_delivered(written_file_path: str) -> None:
40
+ try:
41
+ with open(f"{written_file_path}{RELAY_SPOOL_DELIVERED_MARKER_SUFFIX}", "w", encoding="utf-8"):
42
+ pass
43
+ except OSError:
44
+ # Durable acceptance already happened at the spool write; marker creation is maintenance metadata only.
45
+ return
46
+
47
+
48
+ class AtomicRelayFileTransport:
49
+ def __init__(self, events_dir: str, service_name: str) -> None:
50
+ self._events_dir = os.path.abspath(os.path.normpath(events_dir))
51
+ self._service_name = _sanitize_service_name(service_name)
52
+ self._sequence = 0
53
+ self._dir_ensured = False
54
+ self._lock = threading.Lock()
55
+
56
+ def write(self, events: list[dict[str, Any]]) -> RelayWriteResult:
57
+ if not events:
58
+ return RelayWriteResult(status_code=202)
59
+
60
+ try:
61
+ with self._lock:
62
+ if not self._dir_ensured:
63
+ os.makedirs(self._events_dir, mode=LOCAL_EVENTS_DIRECTORY_MODE, exist_ok=True)
64
+ self._dir_ensured = True
65
+
66
+ timestamp = int(time.time() * 1000)
67
+ self._sequence += 1
68
+ filename = f"{timestamp}-{self._sequence}-{self._service_name}.events.json"
69
+ final_path = os.path.join(self._events_dir, filename)
70
+ tmp_path = f"{final_path}.tmp-{secrets.token_hex(8)}"
71
+
72
+ _assert_not_symlink(final_path)
73
+ _write_secure_temp_file(tmp_path, json.dumps(events, separators=(",", ":")))
74
+ os.replace(tmp_path, final_path)
75
+ return RelayWriteResult(status_code=202, written_file_path=final_path)
76
+ except OSError:
77
+ _cleanup_temp_files(self._events_dir)
78
+ return RelayWriteResult(status_code=500)
79
+
80
+
81
+ class RelayForwardTransport:
82
+ def __init__(self, endpoint: str, transport: Callable[[Mapping[str, object]], object] | None = None) -> None:
83
+ self._transport = transport or HttpTransport(endpoint)
84
+
85
+ def send(self, project_token: str, events: list[dict[str, Any]]) -> tuple[bool, bool]:
86
+ if not project_token:
87
+ return (False, False)
88
+
89
+ try:
90
+ response = coerce_transport_response(
91
+ self._transport(
92
+ {
93
+ "project_token": project_token,
94
+ "events": attach_project_token(events, project_token),
95
+ }
96
+ )
97
+ )
98
+ except Exception:
99
+ return (True, False)
100
+
101
+ return (True, 200 <= response.status_code < 300)
102
+
103
+
104
+ def _sanitize_service_name(service_name: str) -> str:
105
+ normalized = re.sub(r"[^A-Za-z0-9._-]+", "-", service_name.strip())
106
+ normalized = re.sub(r"-+", "-", normalized).strip("-")
107
+ return normalized or "service"
108
+
109
+
110
+ def _assert_not_symlink(target_path: str) -> None:
111
+ try:
112
+ if os.path.islink(target_path):
113
+ raise OSError("symlink_path_rejected")
114
+ except OSError:
115
+ raise
116
+
117
+
118
+ def _write_secure_temp_file(tmp_path: str, payload: str) -> None:
119
+ flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | OPTIONAL_NOFOLLOW_FLAG
120
+ encoded = payload.encode("utf-8")
121
+ fd = os.open(tmp_path, flags, LOCAL_EVENT_FILE_MODE)
122
+ try:
123
+ os.write(fd, encoded)
124
+ finally:
125
+ os.close(fd)
126
+
127
+
128
+ def _cleanup_temp_files(events_dir: str) -> None:
129
+ try:
130
+ for entry in os.listdir(events_dir):
131
+ if ".tmp-" not in entry:
132
+ continue
133
+ try:
134
+ os.remove(os.path.join(events_dir, entry))
135
+ except OSError:
136
+ continue
137
+ except OSError:
138
+ return
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: debugbundle-python
3
- Version: 0.1.5
3
+ Version: 0.1.6
4
4
  Summary: DebugBundle SDK for Python
5
5
  Author: DebugBundle
6
6
  License-Expression: AGPL-3.0-only
7
- Project-URL: Homepage, https://debugbundle.com/docs/sdk-python
7
+ Project-URL: Homepage, https://debugbundle.com/docs/sdks/python
8
8
  Project-URL: Repository, https://github.com/debugbundle/debugbundle-python
9
9
  Project-URL: Issues, https://github.com/debugbundle/debugbundle-python/issues
10
10
  Keywords: debugbundle,debugging,ai-agent,error-tracking
@@ -59,7 +59,7 @@ debugbundle.flush()
59
59
 
60
60
  ## Status
61
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, 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, and safe backend runtime process facts on exception payloads without reading environment variables.
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, 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, full browser relay handler parity with Django/Flask/FastAPI helpers plus local-only and connected delivery modes, and safe backend runtime process facts on exception payloads without reading environment variables.
63
63
 
64
64
  ## Runtime Context
65
65
 
@@ -77,9 +77,25 @@ Backend exception events now include safe runtime process facts when the host ex
77
77
 
78
78
  The SDK does not read or emit environment variables in this runtime block.
79
79
 
80
+ ## Browser Relay
81
+
82
+ The Python SDK includes a framework-agnostic `BrowserRelayHandler` plus framework helpers for the contract-required `POST /debugbundle/browser` endpoint in Python servers that also load `@debugbundle/sdk-browser`.
83
+
84
+ - `create_django_relay_view()` returns a Django view for the relay route.
85
+ - `create_flask_relay_handler()` registers the relay route on a Flask app.
86
+ - `create_fastapi_relay_handler()` registers the relay route on a FastAPI app.
87
+
88
+ The relay handler enforces same-origin or configured allowed origins, requires `Content-Type: application/json`, accepts the canonical `batch` body shape only, rejects bodies larger than `256 KB`, applies per-IP rate limiting, accepts only supported browser event types, strips trust-sensitive headers and fields, forces `sdk_name` to `@debugbundle/sdk-browser`, and preserves browser correlation fields (`request_id`, `trace_id`, `session_id`, and `user_id_hash`) when they are strings or `null`.
89
+
90
+ Delivery behavior matches the shared relay contract across the shipped server SDKs:
91
+
92
+ - `project_mode="local-only"` writes accepted browser events to local event files for CLI processing.
93
+ - `project_mode="connected"` with the default `durable_write=True` writes a durable relay spool record and then forwards to the ingestion API with the server-side project token.
94
+ - `project_mode="connected"` with `durable_write=False` uses the lower-latency forward-only path.
95
+
80
96
  ## Docs
81
97
 
82
- https://debugbundle.com/docs/sdk-python
98
+ https://debugbundle.com/docs/sdks/python
83
99
 
84
100
  ## License
85
101
 
@@ -8,6 +8,7 @@ src/debugbundle/logger_integrations.py
8
8
  src/debugbundle/py.typed
9
9
  src/debugbundle/redaction.py
10
10
  src/debugbundle/relay.py
11
+ src/debugbundle/relay_delivery.py
11
12
  src/debugbundle/suppression.py
12
13
  src/debugbundle/transport.py
13
14
  src/debugbundle/trigger_token.py
@@ -29,6 +30,7 @@ tests/test_framework_integrations.py
29
30
  tests/test_hooks.py
30
31
  tests/test_http_integration.py
31
32
  tests/test_relay.py
33
+ tests/test_relay_delivery.py
32
34
  tests/test_remote_config.py
33
35
  tests/test_repository_metadata.py
34
36
  tests/test_schema_validation.py
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ from pathlib import Path
4
5
 
5
6
  import pytest
6
7
 
@@ -276,4 +277,73 @@ def test_django_relay_view_accepts_valid_batch() -> None:
276
277
 
277
278
  assert response.status_code == 202
278
279
  assert json.loads(response.content) == {"accepted": 1, "errors": [], "rejected": 0}
279
- assert accepted and len(accepted[0].events) == 1
280
+ assert accepted and len(accepted[0].events) == 1
281
+
282
+
283
+ def test_flask_relay_handler_writes_local_only_event_file(tmp_path: Path) -> None:
284
+ from flask import Flask
285
+
286
+ app = Flask(__name__)
287
+ create_flask_relay_handler(
288
+ allowed_origins=["https://example.com"],
289
+ project_mode="local-only",
290
+ local_events_dir=str(tmp_path / "events"),
291
+ )(app)
292
+
293
+ client = app.test_client()
294
+ response = client.post(
295
+ "/debugbundle/browser",
296
+ json=_relay_batch(),
297
+ headers={"Origin": "https://example.com"},
298
+ )
299
+
300
+ assert response.status_code == 202
301
+ assert len(list((tmp_path / "events").glob("*.events.json"))) == 1
302
+
303
+
304
+ def test_fastapi_relay_handler_writes_local_only_event_file(tmp_path: Path) -> None:
305
+ from fastapi import FastAPI
306
+ from fastapi.testclient import TestClient
307
+
308
+ app = FastAPI()
309
+ create_fastapi_relay_handler(
310
+ allowed_origins=["https://example.com"],
311
+ project_mode="local-only",
312
+ local_events_dir=str(tmp_path / "events"),
313
+ )(app)
314
+
315
+ client = TestClient(app)
316
+ response = client.post(
317
+ "/debugbundle/browser",
318
+ json=_relay_batch(),
319
+ headers={"Origin": "https://example.com"},
320
+ )
321
+
322
+ assert response.status_code == 202
323
+ assert len(list((tmp_path / "events").glob("*.events.json"))) == 1
324
+
325
+
326
+ def test_django_relay_view_writes_local_only_event_file(tmp_path: Path) -> None:
327
+ _ensure_django()
328
+
329
+ import json
330
+
331
+ from django.test import RequestFactory
332
+
333
+ factory = RequestFactory()
334
+ view = create_django_relay_view(
335
+ allowed_origins=["https://example.com"],
336
+ project_mode="local-only",
337
+ local_events_dir=str(tmp_path / "events"),
338
+ )
339
+ request = factory.post(
340
+ "/debugbundle/browser",
341
+ data=json.dumps(_relay_batch()),
342
+ content_type="application/json",
343
+ HTTP_ORIGIN="https://example.com",
344
+ )
345
+
346
+ response = view(request)
347
+
348
+ assert response.status_code == 202
349
+ assert len(list((tmp_path / "events").glob("*.events.json"))) == 1
@@ -1,9 +1,21 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ from pathlib import Path
4
5
 
5
6
  from debugbundle.relay import BrowserRelayAcceptedBatch, BrowserRelayHandler
6
7
 
8
+ _RELAY_COMPLIANCE_FIXTURES = json.loads(
9
+ Path(__file__).with_name("fixtures").joinpath("relay-compliance.json").read_text(encoding="utf-8")
10
+ )
11
+
12
+
13
+ def _relay_compliance_fixture(case_id: str) -> dict:
14
+ for fixture in _RELAY_COMPLIANCE_FIXTURES["cases"]:
15
+ if fixture["id"] == case_id:
16
+ return fixture
17
+ raise AssertionError(f"Missing relay compliance fixture: {case_id}")
18
+
7
19
 
8
20
  def _valid_event(event_type: str = "frontend_exception") -> dict:
9
21
  return {
@@ -41,6 +53,16 @@ def _make_request(
41
53
  }
42
54
 
43
55
 
56
+ def _make_request_from_fixture(request: dict) -> dict:
57
+ body = request["bodyText"] if "bodyText" in request else json.dumps(request.get("bodyJson", {"batch": []}))
58
+ return {
59
+ "method": request.get("method", "POST"),
60
+ "headers": request.get("headers", {}),
61
+ "body": body,
62
+ "ip_address": request.get("ipAddress"),
63
+ }
64
+
65
+
44
66
  def test_accepts_valid_batch() -> None:
45
67
  handler = BrowserRelayHandler(allowed_origins=["https://example.com"])
46
68
  response = handler.handle(_make_request())
@@ -122,32 +144,32 @@ def test_rejects_missing_batch_key() -> None:
122
144
 
123
145
 
124
146
  def test_rejects_unsupported_event_type() -> None:
125
- handler = BrowserRelayHandler(allowed_origins=["https://example.com"])
126
- bad_event = _valid_event()
127
- bad_event["event_type"] = "backend_exception"
128
- response = handler.handle(_make_request(body={"batch": [bad_event]}))
129
- assert response.status == 400
130
- assert response.body["rejected"] == 1
131
- assert "backend_exception" in response.body["errors"][0]
147
+ fixture = _relay_compliance_fixture("mixed-valid-invalid-batch")
148
+ handler = BrowserRelayHandler(allowed_origins=[fixture["request"]["headers"]["origin"]])
149
+ response = handler.handle(_make_request_from_fixture(fixture["request"]))
150
+ assert response.status == fixture["expected"]["status"]
151
+ assert response.body["accepted"] == fixture["expected"]["accepted"]
152
+ assert response.body["rejected"] == fixture["expected"]["rejected"]
153
+ assert response.body["errors"] == fixture["expected"]["errors"]
132
154
 
133
155
 
134
156
  def test_strips_trust_sensitive_fields() -> None:
157
+ fixture = _relay_compliance_fixture("credential-smuggling-payload")
135
158
  accepted: list[BrowserRelayAcceptedBatch] = []
136
159
  handler = BrowserRelayHandler(
137
- allowed_origins=["https://example.com"],
160
+ allowed_origins=[fixture["request"]["headers"]["origin"]],
138
161
  on_accept=lambda batch: accepted.append(batch),
139
162
  )
140
- evt = _valid_event()
141
- evt["project_token"] = "dbundle_proj_secret"
142
- evt["organization_id"] = "org_1234"
143
- evt["sdk_name"] = "tampered_sdk_name"
144
- response = handler.handle(_make_request(body={"batch": [evt]}))
145
- assert response.status == 202
163
+ response = handler.handle(_make_request_from_fixture(fixture["request"]))
164
+ assert response.status == fixture["expected"]["status"]
146
165
  assert len(accepted) == 1
147
166
  sanitized = accepted[0].events[0]
167
+ assert "authorization" not in accepted[0].headers
168
+ assert "cookie" not in accepted[0].headers
169
+ assert "x-api-key" not in accepted[0].headers
148
170
  assert "project_token" not in sanitized
149
171
  assert "organization_id" not in sanitized
150
- assert sanitized["sdk_name"] == "@debugbundle/sdk-browser"
172
+ assert sanitized == fixture["expectedEventFile"][0]
151
173
 
152
174
 
153
175
  def test_preserves_correlation_trace_id() -> None:
@@ -217,3 +239,88 @@ def test_on_accept_receives_batch_metadata() -> None:
217
239
  assert accepted[0].ip_address == "9.8.7.6"
218
240
  assert accepted[0].received_at
219
241
  assert "authorization" not in accepted[0].headers
242
+
243
+
244
+ def test_local_only_mode_writes_relay_event_file(tmp_path: Path) -> None:
245
+ events_dir = tmp_path / "events"
246
+ handler = BrowserRelayHandler(
247
+ allowed_origins=["https://example.com"],
248
+ project_mode="local-only",
249
+ local_events_dir=str(events_dir),
250
+ )
251
+
252
+ response = handler.handle(_make_request())
253
+
254
+ assert response.status == 202
255
+ written_files = list(events_dir.glob("*.events.json"))
256
+ assert len(written_files) == 1
257
+ assert json.loads(written_files[0].read_text(encoding="utf-8"))[0]["event_type"] == "frontend_exception"
258
+
259
+
260
+ def test_connected_durable_mode_marks_spool_file_delivered_after_forward_success(tmp_path: Path) -> None:
261
+ spool_dir = tmp_path / "spool"
262
+ forwarded: list[dict[str, object]] = []
263
+
264
+ def forward_transport(request: dict[str, object]) -> object:
265
+ forwarded.append(request)
266
+ return type("Response", (), {"status_code": 202, "retry_after_ms": None})()
267
+
268
+ handler = BrowserRelayHandler(
269
+ allowed_origins=["https://example.com"],
270
+ project_mode="connected",
271
+ project_token="dbundle_proj_test",
272
+ endpoint="https://api.debugbundle.com/v1/events",
273
+ spool_dir=str(spool_dir),
274
+ forward_transport=forward_transport,
275
+ )
276
+
277
+ response = handler.handle(_make_request())
278
+
279
+ assert response.status == 202
280
+ spool_files = list(spool_dir.glob("*.events.json"))
281
+ assert len(spool_files) == 1
282
+ assert spool_files[0].with_name(f"{spool_files[0].name}.delivered").exists()
283
+ assert forwarded
284
+ forwarded_event = forwarded[0]["events"][0]
285
+ assert forwarded_event["project_token"] == "dbundle_proj_test"
286
+
287
+
288
+ def test_connected_durable_mode_retains_spool_file_when_forwarding_fails(tmp_path: Path) -> None:
289
+ spool_dir = tmp_path / "spool"
290
+
291
+ def forward_transport(_: dict[str, object]) -> object:
292
+ return type("Response", (), {"status_code": 500, "retry_after_ms": None})()
293
+
294
+ handler = BrowserRelayHandler(
295
+ allowed_origins=["https://example.com"],
296
+ project_mode="connected",
297
+ project_token="dbundle_proj_test",
298
+ endpoint="https://api.debugbundle.com/v1/events",
299
+ spool_dir=str(spool_dir),
300
+ forward_transport=forward_transport,
301
+ )
302
+
303
+ response = handler.handle(_make_request())
304
+
305
+ assert response.status == 202
306
+ spool_files = list(spool_dir.glob("*.events.json"))
307
+ assert len(spool_files) == 1
308
+ assert not spool_files[0].with_name(f"{spool_files[0].name}.delivered").exists()
309
+
310
+
311
+ def test_connected_low_latency_mode_returns_500_when_forwarding_fails() -> None:
312
+ def forward_transport(_: dict[str, object]) -> object:
313
+ return type("Response", (), {"status_code": 500, "retry_after_ms": None})()
314
+
315
+ handler = BrowserRelayHandler(
316
+ allowed_origins=["https://example.com"],
317
+ project_mode="connected",
318
+ project_token="dbundle_proj_test",
319
+ endpoint="https://api.debugbundle.com/v1/events",
320
+ durable_write=False,
321
+ forward_transport=forward_transport,
322
+ )
323
+
324
+ response = handler.handle(_make_request())
325
+
326
+ assert response.status == 500
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ import debugbundle.relay_delivery as relay_delivery
8
+
9
+
10
+ def test_resolves_default_delivery_directories_and_attaches_project_token(
11
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
12
+ ) -> None:
13
+ monkeypatch.chdir(tmp_path)
14
+
15
+ assert relay_delivery.resolve_default_local_events_dir() == str(tmp_path / ".debugbundle" / "local" / "events")
16
+ assert relay_delivery.resolve_default_relay_spool_dir() == str(
17
+ tmp_path / ".debugbundle" / "local" / "browser-relay-spool"
18
+ )
19
+ assert relay_delivery.attach_project_token([{"event_id": "evt-1"}], "dbundle_proj_test") == [
20
+ {"event_id": "evt-1", "project_token": "dbundle_proj_test"}
21
+ ]
22
+
23
+
24
+ def test_mark_spool_file_delivered_creates_marker_file(tmp_path: Path) -> None:
25
+ written_file = tmp_path / "spool" / "batch.events.json"
26
+ written_file.parent.mkdir(parents=True)
27
+ written_file.write_text("[]", encoding="utf-8")
28
+
29
+ relay_delivery.mark_spool_file_delivered(str(written_file))
30
+
31
+ assert written_file.with_name(f"{written_file.name}{relay_delivery.RELAY_SPOOL_DELIVERED_MARKER_SUFFIX}").exists()
32
+
33
+
34
+ def test_mark_spool_file_delivered_ignores_marker_write_failures(tmp_path: Path) -> None:
35
+ relay_delivery.mark_spool_file_delivered(str(tmp_path / "missing" / "batch.events.json"))
36
+
37
+
38
+ def test_atomic_relay_file_transport_handles_empty_batches() -> None:
39
+ transport = relay_delivery.AtomicRelayFileTransport("/tmp/debugbundle-events", "web api")
40
+
41
+ result = transport.write([])
42
+
43
+ assert result.status_code == 202
44
+ assert result.written_file_path is None
45
+
46
+
47
+ def test_atomic_relay_file_transport_returns_500_when_writes_fail(
48
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
49
+ ) -> None:
50
+ transport = relay_delivery.AtomicRelayFileTransport(str(tmp_path / "events"), "web api")
51
+
52
+ def fail_write(_: str, __: str) -> None:
53
+ raise OSError("disk full")
54
+
55
+ monkeypatch.setattr(relay_delivery, "_write_secure_temp_file", fail_write)
56
+
57
+ result = transport.write([{"event_id": "evt-1"}])
58
+
59
+ assert result.status_code == 500
60
+ assert result.written_file_path is None
61
+
62
+
63
+ def test_relay_forward_transport_handles_missing_token_and_transport_errors() -> None:
64
+ def raising_transport(_: dict[str, object]) -> object:
65
+ raise RuntimeError("boom")
66
+
67
+ transport = relay_delivery.RelayForwardTransport(
68
+ "https://api.debugbundle.test/v1/events", transport=raising_transport
69
+ )
70
+
71
+ assert transport.send("", [{"event_id": "evt-1"}]) == (False, False)
72
+ assert transport.send("dbundle_proj_test", [{"event_id": "evt-1"}]) == (True, False)
73
+
74
+
75
+ def test_assert_not_symlink_rejects_symbolic_links(tmp_path: Path) -> None:
76
+ target = tmp_path / "target.events.json"
77
+ target.write_text("[]", encoding="utf-8")
78
+ symlink_path = tmp_path / "symlink.events.json"
79
+ symlink_path.symlink_to(target)
80
+
81
+ with pytest.raises(OSError, match="symlink_path_rejected"):
82
+ relay_delivery._assert_not_symlink(str(symlink_path))
83
+
84
+
85
+ def test_cleanup_temp_files_ignores_missing_directories_and_remove_failures(
86
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
87
+ ) -> None:
88
+ events_dir = tmp_path / "events"
89
+ events_dir.mkdir()
90
+ stale_tmp_file = events_dir / "stale.tmp-1"
91
+ stale_tmp_file.write_text("stale", encoding="utf-8")
92
+ blocked_tmp_file = events_dir / "blocked.tmp-2"
93
+ blocked_tmp_file.write_text("blocked", encoding="utf-8")
94
+ keep_file = events_dir / "keep.events.json"
95
+ keep_file.write_text("keep", encoding="utf-8")
96
+
97
+ original_remove = relay_delivery.os.remove
98
+
99
+ def flaky_remove(path: str) -> None:
100
+ if path.endswith(blocked_tmp_file.name):
101
+ raise OSError("busy")
102
+ original_remove(path)
103
+
104
+ monkeypatch.setattr(relay_delivery.os, "remove", flaky_remove)
105
+
106
+ relay_delivery._cleanup_temp_files(str(events_dir))
107
+ relay_delivery._cleanup_temp_files(str(tmp_path / "missing"))
108
+
109
+ assert not stale_tmp_file.exists()
110
+ assert blocked_tmp_file.exists()
111
+ assert keep_file.exists()