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.
- {debugbundle_python-0.1.5/src/debugbundle_python.egg-info → debugbundle_python-0.1.6}/PKG-INFO +20 -4
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/README.md +18 -2
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/pyproject.toml +2 -2
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/relay_django.py +18 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/relay_fastapi.py +18 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/relay_flask.py +18 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/relay.py +122 -17
- debugbundle_python-0.1.6/src/debugbundle/relay_delivery.py +138 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6/src/debugbundle_python.egg-info}/PKG-INFO +20 -4
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle_python.egg-info/SOURCES.txt +2 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_framework_integrations.py +71 -1
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_relay.py +122 -15
- debugbundle_python-0.1.6/tests/test_relay_delivery.py +111 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/LICENSE +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/setup.cfg +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/__init__.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/config.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/core.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/__init__.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/common.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/django.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/fastapi.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/flask.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/logger_integrations.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/py.typed +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/redaction.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/suppression.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/transport.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/trigger_token.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle_python.egg-info/dependency_links.txt +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle_python.egg-info/requires.txt +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle_python.egg-info/top_level.txt +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_contracts.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_hooks.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_http_integration.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_remote_config.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_repository_metadata.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_schema_validation.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_sdk.py +0 -0
- {debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/tests/test_trigger_token.py +0 -0
{debugbundle_python-0.1.5/src/debugbundle_python.egg-info → debugbundle_python-0.1.6}/PKG-INFO
RENAMED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: debugbundle-python
|
|
3
|
-
Version: 0.1.
|
|
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/
|
|
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/
|
|
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/
|
|
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.
|
|
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/
|
|
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
|
|
{debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/relay_django.py
RENAMED
|
@@ -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:
|
{debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/relay_fastapi.py
RENAMED
|
@@ -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:
|
{debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/relay_flask.py
RENAMED
|
@@ -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
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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(
|
|
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":
|
|
251
|
-
"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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
{debugbundle_python-0.1.5 → debugbundle_python-0.1.6/src/debugbundle_python.egg-info}/PKG-INFO
RENAMED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: debugbundle-python
|
|
3
|
-
Version: 0.1.
|
|
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/
|
|
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/
|
|
98
|
+
https://debugbundle.com/docs/sdks/python
|
|
83
99
|
|
|
84
100
|
## License
|
|
85
101
|
|
{debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle_python.egg-info/SOURCES.txt
RENAMED
|
@@ -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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
response
|
|
129
|
-
assert response.
|
|
130
|
-
assert response.body["rejected"] ==
|
|
131
|
-
assert
|
|
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=["
|
|
160
|
+
allowed_origins=[fixture["request"]["headers"]["origin"]],
|
|
138
161
|
on_accept=lambda batch: accepted.append(batch),
|
|
139
162
|
)
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/__init__.py
RENAMED
|
File without changes
|
{debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/common.py
RENAMED
|
File without changes
|
{debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/django.py
RENAMED
|
File without changes
|
{debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/integrations/fastapi.py
RENAMED
|
File without changes
|
|
File without changes
|
{debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle/logger_integrations.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle_python.egg-info/requires.txt
RENAMED
|
File without changes
|
{debugbundle_python-0.1.5 → debugbundle_python-0.1.6}/src/debugbundle_python.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|