From 4ab1b6c644873a71297a988e5e6c6ea1130dc47a Mon Sep 17 00:00:00 2001 From: Owen Far Date: Thu, 14 May 2026 11:28:21 +0200 Subject: [PATCH 1/3] Promote configured client error request incidents --- CHANGELOG.md | 3 +++ pyproject.toml | 2 +- src/debugbundle/config.py | 24 +++++++++++++++++++ src/debugbundle/core.py | 14 +++++++++-- tests/test_remote_config.py | 48 ++++++++++++++++++++++++++++++++++++- 5 files changed, 87 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4158a19..f45c8f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Added +- Remote capture-policy parsing now honors `immediate_client_error_statuses` so configured `4xx` responses are emitted as immediate `request_event` incident signals even when generic request capture is disabled. + ## [0.1.2] - 2026-05-12 ### Added diff --git a/pyproject.toml b/pyproject.toml index 3151b03..b027533 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "debugbundle-python" -version = "0.1.3" +version = "0.1.5" description = "DebugBundle SDK for Python" readme = "README.md" requires-python = ">=3.10" diff --git a/src/debugbundle/config.py b/src/debugbundle/config.py index 01e9efb..7c9488f 100644 --- a/src/debugbundle/config.py +++ b/src/debugbundle/config.py @@ -13,6 +13,7 @@ class CapturePolicy: capture_request_events: str capture_breadcrumbs: str capture_probe_events: str + immediate_client_error_statuses: tuple[int, ...] @dataclass(frozen=True) @@ -40,6 +41,7 @@ class RemoteConfigSnapshot: capture_request_events="failures_only", capture_breadcrumbs="exception_only", capture_probe_events="buffer_only", + immediate_client_error_statuses=(), ) MINIMAL_CAPTURE_POLICY = CapturePolicy( @@ -48,6 +50,7 @@ class RemoteConfigSnapshot: capture_request_events="failures_only", capture_breadcrumbs="local_only", capture_probe_events="buffer_only", + immediate_client_error_statuses=(), ) @@ -117,6 +120,9 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None: capture_request_events = _as_non_empty_string(payload.get("capture_request_events")) capture_breadcrumbs = _as_non_empty_string(payload.get("capture_breadcrumbs")) capture_probe_events = _as_non_empty_string(payload.get("capture_probe_events")) + immediate_client_error_statuses = _parse_immediate_client_error_statuses( + payload.get("immediate_client_error_statuses") + ) if capture_logs not in {"off", "error", "warning", "info"}: return None @@ -126,6 +132,8 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None: return None if capture_probe_events not in {"buffer_only", "standalone_when_activated"}: return None + if immediate_client_error_statuses is None: + return None return CapturePolicy( preset=preset, @@ -133,9 +141,25 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None: capture_request_events=capture_request_events, capture_breadcrumbs=capture_breadcrumbs, capture_probe_events=capture_probe_events, + immediate_client_error_statuses=immediate_client_error_statuses, ) +def _parse_immediate_client_error_statuses(value: object) -> tuple[int, ...] | None: + if value is None: + return () + if not isinstance(value, list) or len(value) > 12: + return None + + statuses: list[int] = [] + for item in value: + if not isinstance(item, int) or isinstance(item, bool) or item < 400 or item > 499: + return None + statuses.append(item) + + return tuple(sorted(set(statuses))) + + def _parse_directive(payload: object) -> RemoteProbeDirective | None: if not isinstance(payload, dict): return None diff --git a/src/debugbundle/core.py b/src/debugbundle/core.py index 92c2c92..282567f 100644 --- a/src/debugbundle/core.py +++ b/src/debugbundle/core.py @@ -609,7 +609,11 @@ def _should_capture_request_event(self, response: Mapping[str, object] | None) - candidate = response.get("status_code") or response.get("response_status") if isinstance(candidate, int): status_code = candidate - if _is_immediate_request_incident_status(status_code, self._capture_policy.preset): + if _is_immediate_request_incident_status( + status_code, + self._capture_policy.preset, + self._capture_policy.immediate_client_error_statuses, + ): return True if policy == "off": return False @@ -851,11 +855,17 @@ def _iso_now(time_provider: Callable[[], float]) -> str: return datetime.fromtimestamp(time_provider(), tz=timezone.utc).isoformat().replace("+00:00", "Z") -def _is_immediate_request_incident_status(status_code: int | None, preset: str) -> bool: +def _is_immediate_request_incident_status( + status_code: int | None, + preset: str, + immediate_client_error_statuses: tuple[int, ...], +) -> bool: if status_code is None: return False if status_code >= 500: return True + if status_code in immediate_client_error_statuses: + return True if preset == "investigative": return status_code in INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES if preset == "balanced": diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py index 95cf259..5608124 100644 --- a/tests/test_remote_config.py +++ b/tests/test_remote_config.py @@ -310,4 +310,50 @@ def test_investigative_capture_policy_promotes_409_even_when_request_capture_is_ sdk.flush() events = _events(transport) - assert [event["payload"]["response_status"] for event in events if event["event_type"] == "request_event"] == [409] \ No newline at end of file + assert [event["payload"]["response_status"] for event in events if event["event_type"] == "request_event"] == [409] + + +def test_capture_policy_promotes_configured_client_error_statuses_when_request_capture_is_off() -> None: + clock = ManualClock() + fetch = FakeFetch( + responses=[ + FakeConfigResponse( + 200, + { + "probes_enabled": True, + "remote_probes_enabled": True, + "active_probes": [], + "poll_interval_ms": 15000, + "capture_policy": { + "preset": "minimal", + "capture_logs": "error", + "capture_request_events": "off", + "capture_breadcrumbs": "local_only", + "capture_probe_events": "buffer_only", + "immediate_client_error_statuses": [422, 403, 403], + }, + }, + ) + ] + ) + transport = FakeTransport() + sdk = DebugBundleSdk(transport=transport, time_provider=clock.time) + sdk.init( + project_token="dbundle_proj_test", + service="checkout-api", + environment="production", + fetch_impl=fetch, + ) + + sdk.capture_request({"method": "POST", "path": "/checkout", "headers": {}}, {"status_code": 403}) + sdk.capture_request({"method": "POST", "path": "/checkout", "headers": {}}, {"status_code": 404}) + sdk.capture_request({"method": "POST", "path": "/checkout", "headers": {}}, {"status_code": 422}) + sdk.flush() + + events = _events(transport) + request_statuses = [ + event["payload"]["response_status"] + for event in events + if event["event_type"] == "request_event" + ] + assert request_statuses == [403, 422] From f095fc5a7fb67d8089f09713cf30188a3c00e46d Mon Sep 17 00:00:00 2001 From: Owen Far Date: Tue, 19 May 2026 19:05:12 +0200 Subject: [PATCH 2/3] feat: complete python browser relay parity --- CHANGELOG.md | 3 + README.md | 20 +- pyproject.toml | 4 +- src/debugbundle/integrations/relay_django.py | 18 + src/debugbundle/integrations/relay_fastapi.py | 18 + src/debugbundle/integrations/relay_flask.py | 18 + src/debugbundle/relay.py | 139 +++- src/debugbundle/relay_delivery.py | 138 ++++ tests/fixtures/relay-compliance.json | 635 ++++++++++++++++++ tests/test_framework_integrations.py | 72 +- tests/test_relay.py | 137 +++- 11 files changed, 1165 insertions(+), 37 deletions(-) create mode 100644 src/debugbundle/relay_delivery.py create mode 100644 tests/fixtures/relay-compliance.json diff --git a/CHANGELOG.md b/CHANGELOG.md index f45c8f6..a8c5734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,11 @@ ## [Unreleased] +## [0.1.6] - 2026-05-19 + ### Added - Remote capture-policy parsing now honors `immediate_client_error_statuses` so configured `4xx` responses are emitted as immediate `request_event` incident signals even when generic request capture is disabled. +- Full browser relay delivery parity, including local-only event-file writes, connected durable spool writes, connected cloud forwarding with server-side project credentials, Django/Flask/FastAPI helper coverage, and shared relay compliance fixtures. ## [0.1.2] - 2026-05-12 diff --git a/README.md b/README.md index 7ae8885..0fc03c1 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ debugbundle.flush() ## Status -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. +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. ## Runtime Context @@ -38,9 +38,25 @@ Backend exception events now include safe runtime process facts when the host ex The SDK does not read or emit environment variables in this runtime block. +## Browser Relay + +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`. + +- `create_django_relay_view()` returns a Django view for the relay route. +- `create_flask_relay_handler()` registers the relay route on a Flask app. +- `create_fastapi_relay_handler()` registers the relay route on a FastAPI app. + +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`. + +Delivery behavior matches the shared relay contract across the shipped server SDKs: + +- `project_mode="local-only"` writes accepted browser events to local event files for CLI processing. +- `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. +- `project_mode="connected"` with `durable_write=False` uses the lower-latency forward-only path. + ## Docs -https://debugbundle.com/docs/sdk-python +https://debugbundle.com/docs/sdks/python ## License diff --git a/pyproject.toml b/pyproject.toml index b027533..ca9d5b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "debugbundle-python" -version = "0.1.5" +version = "0.1.6" description = "DebugBundle SDK for Python" readme = "README.md" requires-python = ">=3.10" @@ -44,7 +44,7 @@ dev = [ ] [project.urls] -Homepage = "https://debugbundle.com/docs/sdk-python" +Homepage = "https://debugbundle.com/docs/sdks/python" Repository = "https://github.com/debugbundle/debugbundle-python" Issues = "https://github.com/debugbundle/debugbundle-python/issues" diff --git a/src/debugbundle/integrations/relay_django.py b/src/debugbundle/integrations/relay_django.py index 9a99345..f30757a 100644 --- a/src/debugbundle/integrations/relay_django.py +++ b/src/debugbundle/integrations/relay_django.py @@ -11,12 +11,30 @@ def create_django_relay_view( max_body_bytes: int = 262_144, rate_limit_per_minute: int = 60, on_accept: Any = None, + project_mode: str | None = None, + project_token: str | None = None, + endpoint: str | None = None, + local_events_dir: str | None = None, + spool_dir: str | None = None, + durable_write: bool = True, + service: str | None = None, + environment: str | None = None, + forward_transport: Any = None, ) -> Any: handler = BrowserRelayHandler( allowed_origins=allowed_origins or [], max_body_bytes=max_body_bytes, rate_limit_per_minute=rate_limit_per_minute, on_accept=on_accept, + project_mode=project_mode, + project_token=project_token, + endpoint=endpoint, + local_events_dir=local_events_dir, + spool_dir=spool_dir, + durable_write=durable_write, + service=service, + environment=environment, + forward_transport=forward_transport, ) def view(request: Any) -> Any: diff --git a/src/debugbundle/integrations/relay_fastapi.py b/src/debugbundle/integrations/relay_fastapi.py index e984722..bc8c967 100644 --- a/src/debugbundle/integrations/relay_fastapi.py +++ b/src/debugbundle/integrations/relay_fastapi.py @@ -14,6 +14,15 @@ def create_fastapi_relay_handler( max_body_bytes: int = 262_144, rate_limit_per_minute: int = 60, on_accept: Any = None, + project_mode: str | None = None, + project_token: str | None = None, + endpoint: str | None = None, + local_events_dir: str | None = None, + spool_dir: str | None = None, + durable_write: bool = True, + service: str | None = None, + environment: str | None = None, + forward_transport: Any = None, route_path: str = "/debugbundle/browser", ) -> Any: handler = BrowserRelayHandler( @@ -21,6 +30,15 @@ def create_fastapi_relay_handler( max_body_bytes=max_body_bytes, rate_limit_per_minute=rate_limit_per_minute, on_accept=on_accept, + project_mode=project_mode, + project_token=project_token, + endpoint=endpoint, + local_events_dir=local_events_dir, + spool_dir=spool_dir, + durable_write=durable_write, + service=service, + environment=environment, + forward_transport=forward_transport, ) def register(app: Any) -> None: diff --git a/src/debugbundle/integrations/relay_flask.py b/src/debugbundle/integrations/relay_flask.py index 1c818e4..1a8e1f3 100644 --- a/src/debugbundle/integrations/relay_flask.py +++ b/src/debugbundle/integrations/relay_flask.py @@ -11,6 +11,15 @@ def create_flask_relay_handler( max_body_bytes: int = 262_144, rate_limit_per_minute: int = 60, on_accept: Any = None, + project_mode: str | None = None, + project_token: str | None = None, + endpoint: str | None = None, + local_events_dir: str | None = None, + spool_dir: str | None = None, + durable_write: bool = True, + service: str | None = None, + environment: str | None = None, + forward_transport: Any = None, route_path: str = "/debugbundle/browser", ) -> Any: handler = BrowserRelayHandler( @@ -18,6 +27,15 @@ def create_flask_relay_handler( max_body_bytes=max_body_bytes, rate_limit_per_minute=rate_limit_per_minute, on_accept=on_accept, + project_mode=project_mode, + project_token=project_token, + endpoint=endpoint, + local_events_dir=local_events_dir, + spool_dir=spool_dir, + durable_write=durable_write, + service=service, + environment=environment, + forward_transport=forward_transport, ) def register(app: Any) -> None: diff --git a/src/debugbundle/relay.py b/src/debugbundle/relay.py index 4036127..5f63da6 100644 --- a/src/debugbundle/relay.py +++ b/src/debugbundle/relay.py @@ -2,10 +2,18 @@ import json import time -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass, field from typing import Any +from .relay_delivery import ( + AtomicRelayFileTransport, + RelayForwardTransport, + mark_spool_file_delivered, + resolve_default_local_events_dir, + resolve_default_relay_spool_dir, +) + DEFAULT_MAX_BODY_BYTES = 262_144 DEFAULT_RATE_LIMIT_PER_MINUTE = 60 BROWSER_SDK_NAME = "@debugbundle/sdk-browser" @@ -41,12 +49,32 @@ class BrowserRelayHandler: max_body_bytes: int = DEFAULT_MAX_BODY_BYTES rate_limit_per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE on_accept: Callable[[BrowserRelayAcceptedBatch], None] | None = None + project_mode: str | None = None + project_token: str | None = None + endpoint: str | None = None + local_events_dir: str | None = None + spool_dir: str | None = None + durable_write: bool = True + service: str | None = None + environment: str | None = None + forward_transport: Callable[[Mapping[str, object]], object] | None = None def __post_init__(self) -> None: self.allowed_origins = [o for o in self.allowed_origins if o] self.max_body_bytes = max(1, self.max_body_bytes) self.rate_limit_per_minute = max(1, self.rate_limit_per_minute) + normalized_project_mode = (self.project_mode or "").strip().lower() + self.project_mode = normalized_project_mode or None + if self.project_mode not in {None, "connected", "local-only"}: + self.project_mode = None self._rate_limit_state: dict[str, list[int]] = {} + self._local_transports: dict[str, AtomicRelayFileTransport] = {} + self._spool_transports: dict[str, AtomicRelayFileTransport] = {} + self._forwarder = ( + RelayForwardTransport(self.endpoint, self.forward_transport) + if self.project_mode == "connected" and self.endpoint is not None + else None + ) def handle(self, request: dict[str, Any]) -> BrowserRelayResponse: method = str(request.get("method", "POST")).upper() @@ -106,22 +134,29 @@ def handle(self, request: dict[str, Any]) -> BrowserRelayResponse: errors.append(f"batch[{index}]: Unsupported browser relay event type {type_label}.") continue - sanitized = _sanitize_event(candidate) + sanitized = _sanitize_event(candidate, service_override=self.service, environment_override=self.environment) if sanitized is None: errors.append(f"batch[{index}]: Invalid browser relay event payload.") continue accepted_events.append(sanitized) - if accepted_events and self.on_accept is not None: - self.on_accept( - BrowserRelayAcceptedBatch( - events=accepted_events, - headers=_strip_sensitive_headers(headers), - ip_address=ip_address, - received_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - ) - ) + if accepted_events: + try: + if not self._deliver_events(accepted_events): + return BrowserRelayResponse(500) + + if self.on_accept is not None: + self.on_accept( + BrowserRelayAcceptedBatch( + events=accepted_events, + headers=_strip_sensitive_headers(headers), + ip_address=ip_address, + received_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + ) + ) + except Exception: + return BrowserRelayResponse(500) if errors: return BrowserRelayResponse( @@ -134,6 +169,54 @@ def handle(self, request: dict[str, Any]) -> BrowserRelayResponse: {"accepted": len(accepted_events), "rejected": 0, "errors": []}, ) + def _deliver_events(self, accepted_events: list[dict[str, Any]]) -> bool: + if self.project_mode is None: + return True + + service_name = self.service or str(accepted_events[0]["service"]["name"]) + + if self.project_mode == "local-only": + local_transport = self._local_transports.get(service_name) + if local_transport is None: + local_transport = AtomicRelayFileTransport( + self.local_events_dir or resolve_default_local_events_dir(), + service_name, + ) + self._local_transports[service_name] = local_transport + + return local_transport.write(accepted_events).status_code == 202 + + if self.project_mode != "connected": + return True + + if self.durable_write: + spool_transport = self._spool_transports.get(service_name) + if spool_transport is None: + spool_transport = AtomicRelayFileTransport( + self.spool_dir or resolve_default_relay_spool_dir(), + service_name, + ) + self._spool_transports[service_name] = spool_transport + + spool_write_result = spool_transport.write(accepted_events) + if spool_write_result.status_code != 202: + return False + + configured, succeeded = self._forward_connected_events(accepted_events) + if succeeded and spool_write_result.written_file_path is not None: + mark_spool_file_delivered(spool_write_result.written_file_path) + + return True if configured or spool_write_result.written_file_path is not None else False + + configured, succeeded = self._forward_connected_events(accepted_events) + return configured and succeeded + + def _forward_connected_events(self, accepted_events: list[dict[str, Any]]) -> tuple[bool, bool]: + if self._forwarder is None or not self.project_token: + return (False, False) + + return self._forwarder.send(self.project_token, accepted_events) + def _is_origin_allowed(self, headers: dict[str, str]) -> bool: origin = _source_origin(headers) if origin is None: @@ -210,7 +293,11 @@ def _strip_sensitive_headers(headers: dict[str, str]) -> dict[str, str]: return sanitized -def _sanitize_event(event: dict[str, Any]) -> dict[str, Any] | None: +def _sanitize_event( + event: dict[str, Any], + service_override: str | None = None, + environment_override: str | None = None, +) -> dict[str, Any] | None: schema_version = event.get("schema_version") event_id = event.get("event_id") event_type = event.get("event_type") @@ -239,6 +326,11 @@ def _sanitize_event(event: dict[str, Any]) -> dict[str, Any] | None: if not isinstance(service_name, str) or not service_name or not isinstance(environment, str) or not environment: return None + normalized_service_name = service_override or service_name + normalized_environment = environment_override or environment + if not normalized_service_name or not normalized_environment: + return None + sanitized: dict[str, Any] = { "schema_version": schema_version, "event_id": event_id, @@ -247,16 +339,29 @@ def _sanitize_event(event: dict[str, Any]) -> dict[str, Any] | None: "sdk_name": BROWSER_SDK_NAME, "sdk_version": sdk_version, "service": { - "name": service_name, - "environment": environment, + "name": normalized_service_name, + "environment": normalized_environment, }, "payload": payload, } + runtime = service.get("runtime") + if isinstance(runtime, str) or runtime is None: + sanitized["service"]["runtime"] = runtime + + framework = service.get("framework") + if isinstance(framework, str) or framework is None: + sanitized["service"]["framework"] = framework + correlation = event.get("correlation") if isinstance(correlation, dict): - trace_id = correlation.get("trace_id") - if isinstance(trace_id, str) or trace_id is None: - sanitized["correlation"] = {"trace_id": trace_id} + normalized_correlation: dict[str, Any] = {} + for key in ("request_id", "trace_id", "session_id", "user_id_hash"): + value = correlation.get(key) + if isinstance(value, str) or value is None: + normalized_correlation[key] = value + + if normalized_correlation: + sanitized["correlation"] = normalized_correlation return sanitized diff --git a/src/debugbundle/relay_delivery.py b/src/debugbundle/relay_delivery.py new file mode 100644 index 0000000..ba3f31e --- /dev/null +++ b/src/debugbundle/relay_delivery.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +import os +import re +import secrets +import threading +import time +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any + +from .transport import HttpTransport, coerce_transport_response + +LOCAL_EVENTS_DIRECTORY_MODE = 0o700 +LOCAL_EVENT_FILE_MODE = 0o600 +RELAY_SPOOL_DELIVERED_MARKER_SUFFIX = ".delivered" +OPTIONAL_NOFOLLOW_FLAG = getattr(os, "O_NOFOLLOW", 0) + + +@dataclass(frozen=True) +class RelayWriteResult: + status_code: int + written_file_path: str | None = None + + +def resolve_default_local_events_dir(cwd: str | None = None) -> str: + return os.path.join(cwd or os.getcwd(), ".debugbundle", "local", "events") + + +def resolve_default_relay_spool_dir(cwd: str | None = None) -> str: + return os.path.join(cwd or os.getcwd(), ".debugbundle", "local", "browser-relay-spool") + + +def attach_project_token(events: list[dict[str, Any]], project_token: str) -> list[dict[str, Any]]: + return [{**event, "project_token": project_token} for event in events] + + +def mark_spool_file_delivered(written_file_path: str) -> None: + try: + with open(f"{written_file_path}{RELAY_SPOOL_DELIVERED_MARKER_SUFFIX}", "w", encoding="utf-8"): + pass + except OSError: + # Durable acceptance already happened at the spool write; marker creation is maintenance metadata only. + return + + +class AtomicRelayFileTransport: + def __init__(self, events_dir: str, service_name: str) -> None: + self._events_dir = os.path.abspath(os.path.normpath(events_dir)) + self._service_name = _sanitize_service_name(service_name) + self._sequence = 0 + self._dir_ensured = False + self._lock = threading.Lock() + + def write(self, events: list[dict[str, Any]]) -> RelayWriteResult: + if not events: + return RelayWriteResult(status_code=202) + + try: + with self._lock: + if not self._dir_ensured: + os.makedirs(self._events_dir, mode=LOCAL_EVENTS_DIRECTORY_MODE, exist_ok=True) + self._dir_ensured = True + + timestamp = int(time.time() * 1000) + self._sequence += 1 + filename = f"{timestamp}-{self._sequence}-{self._service_name}.events.json" + final_path = os.path.join(self._events_dir, filename) + tmp_path = f"{final_path}.tmp-{secrets.token_hex(8)}" + + _assert_not_symlink(final_path) + _write_secure_temp_file(tmp_path, json.dumps(events, separators=(",", ":"))) + os.replace(tmp_path, final_path) + return RelayWriteResult(status_code=202, written_file_path=final_path) + except OSError: + _cleanup_temp_files(self._events_dir) + return RelayWriteResult(status_code=500) + + +class RelayForwardTransport: + def __init__(self, endpoint: str, transport: Callable[[Mapping[str, object]], object] | None = None) -> None: + self._transport = transport or HttpTransport(endpoint) + + def send(self, project_token: str, events: list[dict[str, Any]]) -> tuple[bool, bool]: + if not project_token: + return (False, False) + + try: + response = coerce_transport_response( + self._transport( + { + "project_token": project_token, + "events": attach_project_token(events, project_token), + } + ) + ) + except Exception: + return (True, False) + + return (True, 200 <= response.status_code < 300) + + +def _sanitize_service_name(service_name: str) -> str: + normalized = re.sub(r"[^A-Za-z0-9._-]+", "-", service_name.strip()) + normalized = re.sub(r"-+", "-", normalized).strip("-") + return normalized or "service" + + +def _assert_not_symlink(target_path: str) -> None: + try: + if os.path.islink(target_path): + raise OSError("symlink_path_rejected") + except OSError: + raise + + +def _write_secure_temp_file(tmp_path: str, payload: str) -> None: + flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | OPTIONAL_NOFOLLOW_FLAG + encoded = payload.encode("utf-8") + fd = os.open(tmp_path, flags, LOCAL_EVENT_FILE_MODE) + try: + os.write(fd, encoded) + finally: + os.close(fd) + + +def _cleanup_temp_files(events_dir: str) -> None: + try: + for entry in os.listdir(events_dir): + if ".tmp-" not in entry: + continue + try: + os.remove(os.path.join(events_dir, entry)) + except OSError: + continue + except OSError: + return \ No newline at end of file diff --git a/tests/fixtures/relay-compliance.json b/tests/fixtures/relay-compliance.json new file mode 100644 index 0000000..6a735b1 --- /dev/null +++ b/tests/fixtures/relay-compliance.json @@ -0,0 +1,635 @@ +{ + "version": 1, + "cases": [ + { + "id": "valid-browser-batch", + "kind": "handler", + "request": { + "method": "POST", + "headers": { + "content-type": "application/json; charset=utf-8", + "host": "app.example.com", + "origin": "https://app.example.com" + }, + "ipAddress": "203.0.113.10", + "bodyJson": { + "batch": [ + { + "schema_version": "2026-03-01", + "event_id": "00000000-0000-4000-8000-000000000401", + "event_type": "frontend_exception", + "sdk_name": "evil-browser-sdk", + "sdk_version": "1.2.3", + "occurred_at": "2026-05-19T10:00:00.000Z", + "service": { + "name": "checkout-web", + "runtime": "browser", + "framework": "react", + "environment": "production" + }, + "correlation": { + "request_id": null, + "trace_id": "11111111-1111-4111-8111-111111111111", + "session_id": null, + "user_id_hash": null + }, + "payload": { + "name": "TypeError", + "message": "Cannot read properties of undefined", + "stack": "TypeError: Cannot read properties of undefined\n at CheckoutButton.tsx:12:5", + "route": "/checkout", + "browser": { + "name": "Chrome", + "version": "135.0.0" + }, + "breadcrumbs": [ + { + "breadcrumb_type": "route_change", + "route": "/checkout", + "data": {}, + "ts": "2026-05-19T09:59:59.000Z" + } + ], + "device": { + "user_agent": "Mozilla/5.0", + "os": { + "name": "macOS", + "version": "15.0" + }, + "device_type": "desktop", + "screen": { + "width": 1728, + "height": 1117 + }, + "viewport": { + "width": 1440, + "height": 900 + }, + "device_pixel_ratio": 2, + "touch_capable": false, + "language": "en-US", + "connection_type": "4g", + "color_scheme_preference": "light" + }, + "dom_context": { + "mode": "lightweight", + "html_excerpt": "" + } + } + } + ] + } + }, + "expected": { + "status": 202, + "accepted": 1, + "rejected": 0, + "errors": [] + } + }, + { + "id": "mixed-valid-invalid-batch", + "kind": "handler", + "request": { + "method": "POST", + "headers": { + "content-type": "application/json; charset=utf-8", + "host": "app.example.com", + "origin": "https://app.example.com" + }, + "ipAddress": "203.0.113.10", + "bodyJson": { + "batch": [ + { + "schema_version": "2026-03-01", + "event_id": "00000000-0000-4000-8000-000000000402", + "event_type": "frontend_exception", + "sdk_name": "@debugbundle/sdk-browser", + "sdk_version": "1.2.3", + "occurred_at": "2026-05-19T10:01:00.000Z", + "service": { + "name": "checkout-web", + "runtime": "browser", + "framework": "react", + "environment": "production" + }, + "payload": { + "name": "TypeError", + "message": "Cannot read properties of undefined", + "stack": "TypeError: Cannot read properties of undefined\n at CheckoutButton.tsx:12:5", + "route": "/checkout", + "browser": { + "name": "Chrome", + "version": "135.0.0" + } + } + }, + { + "schema_version": "2026-03-01", + "event_id": "00000000-0000-4000-8000-000000000403", + "event_type": "backend_exception", + "sdk_name": "@debugbundle/sdk-node", + "sdk_version": "0.1.0", + "occurred_at": "2026-05-19T10:01:30.000Z", + "service": { + "name": "checkout-api", + "runtime": "node", + "framework": "fastify", + "environment": "production" + }, + "payload": { + "name": "Error", + "message": "boom", + "stack": "Error: boom" + } + } + ] + } + }, + "expected": { + "status": 400, + "accepted": 1, + "rejected": 1, + "errors": [ + "batch[1]: Unsupported browser relay event type backend_exception." + ] + } + }, + { + "id": "credential-smuggling-payload", + "kind": "handler", + "request": { + "method": "POST", + "headers": { + "content-type": "application/json; charset=utf-8", + "host": "app.example.com", + "origin": "https://app.example.com", + "authorization": "Bearer browser-should-not-send-this", + "cookie": "session=browser-cookie", + "x-api-key": "browser-key" + }, + "ipAddress": "203.0.113.11", + "bodyJson": { + "batch": [ + { + "schema_version": "2026-03-01", + "event_id": "00000000-0000-4000-8000-000000000404", + "event_type": "frontend_exception", + "project_token": "dbundle_proj_stolen", + "organization_id": "org_123", + "sdk_name": "spoofed-sdk", + "sdk_version": "1.2.3", + "occurred_at": "2026-05-19T10:02:00.000Z", + "service": { + "name": "checkout-web", + "runtime": "browser", + "framework": "react", + "environment": "production" + }, + "correlation": { + "request_id": null, + "trace_id": "11111111-1111-4111-8111-111111111111", + "session_id": null, + "user_id_hash": null + }, + "payload": { + "name": "TypeError", + "message": "Cannot read properties of undefined", + "stack": "TypeError: Cannot read properties of undefined\n at CheckoutButton.tsx:12:5", + "route": "/checkout", + "browser": { + "name": "Chrome", + "version": "135.0.0" + } + } + } + ] + } + }, + "expected": { + "status": 202, + "accepted": 1, + "rejected": 0, + "errors": [] + }, + "expectedEventFile": [ + { + "schema_version": "2026-03-01", + "event_id": "00000000-0000-4000-8000-000000000404", + "event_type": "frontend_exception", + "sdk_name": "@debugbundle/sdk-browser", + "sdk_version": "1.2.3", + "occurred_at": "2026-05-19T10:02:00.000Z", + "service": { + "name": "checkout-web", + "runtime": "browser", + "framework": "react", + "environment": "production" + }, + "correlation": { + "request_id": null, + "trace_id": "11111111-1111-4111-8111-111111111111", + "session_id": null, + "user_id_hash": null + }, + "payload": { + "name": "TypeError", + "message": "Cannot read properties of undefined", + "stack": "TypeError: Cannot read properties of undefined\n at CheckoutButton.tsx:12:5", + "route": "/checkout", + "browser": { + "name": "Chrome", + "version": "135.0.0" + } + } + } + ] + }, + { + "id": "wrong-origin-request", + "kind": "handler", + "request": { + "method": "POST", + "headers": { + "content-type": "application/json; charset=utf-8", + "host": "app.example.com", + "origin": "https://evil.example.com" + }, + "ipAddress": "203.0.113.12", + "bodyJson": { + "batch": [] + } + }, + "expected": { + "status": 403 + } + }, + { + "id": "missing-origin-request", + "kind": "handler", + "request": { + "method": "POST", + "headers": { + "content-type": "application/json; charset=utf-8", + "host": "app.example.com" + }, + "ipAddress": "203.0.113.13", + "bodyJson": { + "batch": [] + } + }, + "expected": { + "status": 403 + } + }, + { + "id": "oversized-body", + "kind": "handler", + "request": { + "method": "POST", + "headers": { + "content-type": "application/json; charset=utf-8", + "host": "app.example.com", + "origin": "https://app.example.com" + }, + "ipAddress": "203.0.113.14", + "bodyGenerator": { + "kind": "repeat", + "char": "x", + "length": 262145 + } + }, + "expected": { + "status": 413 + } + }, + { + "id": "rate-limit-sequence", + "kind": "sequence", + "relayOptions": { + "rateLimitPerMinute": 3 + }, + "requests": [ + { + "atMs": 0, + "request": { + "method": "POST", + "headers": { + "content-type": "application/json; charset=utf-8", + "host": "app.example.com", + "origin": "https://app.example.com" + }, + "ipAddress": "203.0.113.15", + "bodyJson": { + "batch": [] + } + }, + "expectedStatus": 202 + }, + { + "atMs": 1, + "request": { + "method": "POST", + "headers": { + "content-type": "application/json; charset=utf-8", + "host": "app.example.com", + "origin": "https://app.example.com" + }, + "ipAddress": "203.0.113.15", + "bodyJson": { + "batch": [] + } + }, + "expectedStatus": 202 + }, + { + "atMs": 2, + "request": { + "method": "POST", + "headers": { + "content-type": "application/json; charset=utf-8", + "host": "app.example.com", + "origin": "https://app.example.com" + }, + "ipAddress": "203.0.113.15", + "bodyJson": { + "batch": [] + } + }, + "expectedStatus": 202 + }, + { + "atMs": 3, + "request": { + "method": "POST", + "headers": { + "content-type": "application/json; charset=utf-8", + "host": "app.example.com", + "origin": "https://app.example.com" + }, + "ipAddress": "203.0.113.15", + "bodyJson": { + "batch": [] + } + }, + "expectedStatus": 429 + }, + { + "atMs": 60005, + "request": { + "method": "POST", + "headers": { + "content-type": "application/json; charset=utf-8", + "host": "app.example.com", + "origin": "https://app.example.com" + }, + "ipAddress": "203.0.113.15", + "bodyJson": { + "batch": [] + } + }, + "expectedStatus": 202 + } + ] + }, + { + "id": "local-only-write", + "kind": "delivery", + "relayOptions": { + "projectMode": "local-only", + "service": "checkout-web", + "environment": "production" + }, + "request": { + "method": "POST", + "headers": { + "content-type": "application/json; charset=utf-8", + "host": "app.example.com", + "origin": "https://app.example.com" + }, + "ipAddress": "203.0.113.16", + "bodyJson": { + "batch": [ + { + "schema_version": "2026-03-01", + "event_id": "00000000-0000-4000-8000-000000000405", + "event_type": "frontend_exception", + "sdk_name": "@debugbundle/sdk-browser", + "sdk_version": "1.2.3", + "occurred_at": "2026-05-19T10:03:00.000Z", + "service": { + "name": "ignored-browser-service", + "runtime": "browser", + "framework": "react", + "environment": "ignored-browser-env" + }, + "payload": { + "name": "TypeError", + "message": "Cannot read properties of undefined", + "stack": "TypeError: Cannot read properties of undefined\n at CheckoutButton.tsx:12:5", + "route": "/checkout", + "browser": { + "name": "Chrome", + "version": "135.0.0" + } + } + } + ] + } + }, + "expected": { + "status": 202, + "accepted": 1, + "rejected": 0, + "errors": [] + }, + "expectedEventFile": [ + { + "schema_version": "2026-03-01", + "event_id": "00000000-0000-4000-8000-000000000405", + "event_type": "frontend_exception", + "sdk_name": "@debugbundle/sdk-browser", + "sdk_version": "1.2.3", + "occurred_at": "2026-05-19T10:03:00.000Z", + "service": { + "name": "checkout-web", + "runtime": "browser", + "framework": "react", + "environment": "production" + }, + "payload": { + "name": "TypeError", + "message": "Cannot read properties of undefined", + "stack": "TypeError: Cannot read properties of undefined\n at CheckoutButton.tsx:12:5", + "route": "/checkout", + "browser": { + "name": "Chrome", + "version": "135.0.0" + } + } + } + ] + }, + { + "id": "connected-durable-spool", + "kind": "delivery", + "relayOptions": { + "projectMode": "connected", + "projectToken": "dbundle_proj_test", + "durableWrite": true + }, + "request": { + "method": "POST", + "headers": { + "content-type": "application/json; charset=utf-8", + "host": "app.example.com", + "origin": "https://app.example.com" + }, + "ipAddress": "203.0.113.17", + "bodyJson": { + "batch": [ + { + "schema_version": "2026-03-01", + "event_id": "00000000-0000-4000-8000-000000000406", + "event_type": "frontend_exception", + "sdk_name": "@debugbundle/sdk-browser", + "sdk_version": "1.2.3", + "occurred_at": "2026-05-19T10:04:00.000Z", + "service": { + "name": "checkout-web", + "runtime": "browser", + "framework": "react", + "environment": "production" + }, + "payload": { + "name": "TypeError", + "message": "Cannot read properties of undefined", + "stack": "TypeError: Cannot read properties of undefined\n at CheckoutButton.tsx:12:5", + "route": "/checkout", + "browser": { + "name": "Chrome", + "version": "135.0.0" + } + } + } + ] + } + }, + "expected": { + "status": 202, + "accepted": 1, + "rejected": 0, + "errors": [] + }, + "expectedEventFile": [ + { + "schema_version": "2026-03-01", + "event_id": "00000000-0000-4000-8000-000000000406", + "event_type": "frontend_exception", + "sdk_name": "@debugbundle/sdk-browser", + "sdk_version": "1.2.3", + "occurred_at": "2026-05-19T10:04:00.000Z", + "service": { + "name": "checkout-web", + "runtime": "browser", + "framework": "react", + "environment": "production" + }, + "payload": { + "name": "TypeError", + "message": "Cannot read properties of undefined", + "stack": "TypeError: Cannot read properties of undefined\n at CheckoutButton.tsx:12:5", + "route": "/checkout", + "browser": { + "name": "Chrome", + "version": "135.0.0" + } + } + } + ], + "expectedDeliveredMarker": true + }, + { + "id": "connected-cloud-forwarding", + "kind": "delivery", + "relayOptions": { + "projectMode": "connected", + "projectToken": "dbundle_proj_test", + "durableWrite": false, + "endpoint": "https://api.debugbundle.com/v1/events" + }, + "request": { + "method": "POST", + "headers": { + "content-type": "application/json; charset=utf-8", + "host": "app.example.com", + "origin": "https://app.example.com" + }, + "ipAddress": "203.0.113.18", + "bodyJson": { + "batch": [ + { + "schema_version": "2026-03-01", + "event_id": "00000000-0000-4000-8000-000000000407", + "event_type": "frontend_exception", + "sdk_name": "@debugbundle/sdk-browser", + "sdk_version": "1.2.3", + "occurred_at": "2026-05-19T10:05:00.000Z", + "service": { + "name": "checkout-web", + "runtime": "browser", + "framework": "react", + "environment": "production" + }, + "payload": { + "name": "TypeError", + "message": "Cannot read properties of undefined", + "stack": "TypeError: Cannot read properties of undefined\n at CheckoutButton.tsx:12:5", + "route": "/checkout", + "browser": { + "name": "Chrome", + "version": "135.0.0" + } + } + } + ] + } + }, + "expected": { + "status": 202, + "accepted": 1, + "rejected": 0, + "errors": [] + }, + "expectedForwardRequest": { + "events": [ + { + "schema_version": "2026-03-01", + "event_id": "00000000-0000-4000-8000-000000000407", + "event_type": "frontend_exception", + "sdk_name": "@debugbundle/sdk-browser", + "sdk_version": "1.2.3", + "project_token": "dbundle_proj_test", + "occurred_at": "2026-05-19T10:05:00.000Z", + "service": { + "name": "checkout-web", + "runtime": "browser", + "framework": "react", + "environment": "production" + }, + "payload": { + "name": "TypeError", + "message": "Cannot read properties of undefined", + "stack": "TypeError: Cannot read properties of undefined\n at CheckoutButton.tsx:12:5", + "route": "/checkout", + "browser": { + "name": "Chrome", + "version": "135.0.0" + } + } + } + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/test_framework_integrations.py b/tests/test_framework_integrations.py index 2d591b5..0f83319 100644 --- a/tests/test_framework_integrations.py +++ b/tests/test_framework_integrations.py @@ -1,6 +1,7 @@ from __future__ import annotations import logging +from pathlib import Path import pytest @@ -276,4 +277,73 @@ def test_django_relay_view_accepts_valid_batch() -> None: assert response.status_code == 202 assert json.loads(response.content) == {"accepted": 1, "errors": [], "rejected": 0} - assert accepted and len(accepted[0].events) == 1 \ No newline at end of file + assert accepted and len(accepted[0].events) == 1 + + +def test_flask_relay_handler_writes_local_only_event_file(tmp_path: Path) -> None: + from flask import Flask + + app = Flask(__name__) + create_flask_relay_handler( + allowed_origins=["https://example.com"], + project_mode="local-only", + local_events_dir=str(tmp_path / "events"), + )(app) + + client = app.test_client() + response = client.post( + "/debugbundle/browser", + json=_relay_batch(), + headers={"Origin": "https://example.com"}, + ) + + assert response.status_code == 202 + assert len(list((tmp_path / "events").glob("*.events.json"))) == 1 + + +def test_fastapi_relay_handler_writes_local_only_event_file(tmp_path: Path) -> None: + from fastapi import FastAPI + from fastapi.testclient import TestClient + + app = FastAPI() + create_fastapi_relay_handler( + allowed_origins=["https://example.com"], + project_mode="local-only", + local_events_dir=str(tmp_path / "events"), + )(app) + + client = TestClient(app) + response = client.post( + "/debugbundle/browser", + json=_relay_batch(), + headers={"Origin": "https://example.com"}, + ) + + assert response.status_code == 202 + assert len(list((tmp_path / "events").glob("*.events.json"))) == 1 + + +def test_django_relay_view_writes_local_only_event_file(tmp_path: Path) -> None: + _ensure_django() + + import json + + from django.test import RequestFactory + + factory = RequestFactory() + view = create_django_relay_view( + allowed_origins=["https://example.com"], + project_mode="local-only", + local_events_dir=str(tmp_path / "events"), + ) + request = factory.post( + "/debugbundle/browser", + data=json.dumps(_relay_batch()), + content_type="application/json", + HTTP_ORIGIN="https://example.com", + ) + + response = view(request) + + assert response.status_code == 202 + assert len(list((tmp_path / "events").glob("*.events.json"))) == 1 \ No newline at end of file diff --git a/tests/test_relay.py b/tests/test_relay.py index d5cf64d..72856dc 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -1,9 +1,21 @@ from __future__ import annotations import json +from pathlib import Path from debugbundle.relay import BrowserRelayAcceptedBatch, BrowserRelayHandler +_RELAY_COMPLIANCE_FIXTURES = json.loads( + Path(__file__).with_name("fixtures").joinpath("relay-compliance.json").read_text(encoding="utf-8") +) + + +def _relay_compliance_fixture(case_id: str) -> dict: + for fixture in _RELAY_COMPLIANCE_FIXTURES["cases"]: + if fixture["id"] == case_id: + return fixture + raise AssertionError(f"Missing relay compliance fixture: {case_id}") + def _valid_event(event_type: str = "frontend_exception") -> dict: return { @@ -41,6 +53,16 @@ def _make_request( } +def _make_request_from_fixture(request: dict) -> dict: + body = request["bodyText"] if "bodyText" in request else json.dumps(request.get("bodyJson", {"batch": []})) + return { + "method": request.get("method", "POST"), + "headers": request.get("headers", {}), + "body": body, + "ip_address": request.get("ipAddress"), + } + + def test_accepts_valid_batch() -> None: handler = BrowserRelayHandler(allowed_origins=["https://example.com"]) response = handler.handle(_make_request()) @@ -122,32 +144,32 @@ def test_rejects_missing_batch_key() -> None: def test_rejects_unsupported_event_type() -> None: - handler = BrowserRelayHandler(allowed_origins=["https://example.com"]) - bad_event = _valid_event() - bad_event["event_type"] = "backend_exception" - response = handler.handle(_make_request(body={"batch": [bad_event]})) - assert response.status == 400 - assert response.body["rejected"] == 1 - assert "backend_exception" in response.body["errors"][0] + fixture = _relay_compliance_fixture("mixed-valid-invalid-batch") + handler = BrowserRelayHandler(allowed_origins=[fixture["request"]["headers"]["origin"]]) + response = handler.handle(_make_request_from_fixture(fixture["request"])) + assert response.status == fixture["expected"]["status"] + assert response.body["accepted"] == fixture["expected"]["accepted"] + assert response.body["rejected"] == fixture["expected"]["rejected"] + assert response.body["errors"] == fixture["expected"]["errors"] def test_strips_trust_sensitive_fields() -> None: + fixture = _relay_compliance_fixture("credential-smuggling-payload") accepted: list[BrowserRelayAcceptedBatch] = [] handler = BrowserRelayHandler( - allowed_origins=["https://example.com"], + allowed_origins=[fixture["request"]["headers"]["origin"]], on_accept=lambda batch: accepted.append(batch), ) - evt = _valid_event() - evt["project_token"] = "dbundle_proj_secret" - evt["organization_id"] = "org_1234" - evt["sdk_name"] = "tampered_sdk_name" - response = handler.handle(_make_request(body={"batch": [evt]})) - assert response.status == 202 + response = handler.handle(_make_request_from_fixture(fixture["request"])) + assert response.status == fixture["expected"]["status"] assert len(accepted) == 1 sanitized = accepted[0].events[0] + assert "authorization" not in accepted[0].headers + assert "cookie" not in accepted[0].headers + assert "x-api-key" not in accepted[0].headers assert "project_token" not in sanitized assert "organization_id" not in sanitized - assert sanitized["sdk_name"] == "@debugbundle/sdk-browser" + assert sanitized == fixture["expectedEventFile"][0] def test_preserves_correlation_trace_id() -> None: @@ -217,3 +239,88 @@ def test_on_accept_receives_batch_metadata() -> None: assert accepted[0].ip_address == "9.8.7.6" assert accepted[0].received_at assert "authorization" not in accepted[0].headers + + +def test_local_only_mode_writes_relay_event_file(tmp_path: Path) -> None: + events_dir = tmp_path / "events" + handler = BrowserRelayHandler( + allowed_origins=["https://example.com"], + project_mode="local-only", + local_events_dir=str(events_dir), + ) + + response = handler.handle(_make_request()) + + assert response.status == 202 + written_files = list(events_dir.glob("*.events.json")) + assert len(written_files) == 1 + assert json.loads(written_files[0].read_text(encoding="utf-8"))[0]["event_type"] == "frontend_exception" + + +def test_connected_durable_mode_marks_spool_file_delivered_after_forward_success(tmp_path: Path) -> None: + spool_dir = tmp_path / "spool" + forwarded: list[dict[str, object]] = [] + + def forward_transport(request: dict[str, object]) -> object: + forwarded.append(request) + return type("Response", (), {"status_code": 202, "retry_after_ms": None})() + + handler = BrowserRelayHandler( + allowed_origins=["https://example.com"], + project_mode="connected", + project_token="dbundle_proj_test", + endpoint="https://api.debugbundle.com/v1/events", + spool_dir=str(spool_dir), + forward_transport=forward_transport, + ) + + response = handler.handle(_make_request()) + + assert response.status == 202 + spool_files = list(spool_dir.glob("*.events.json")) + assert len(spool_files) == 1 + assert spool_files[0].with_name(f"{spool_files[0].name}.delivered").exists() + assert forwarded + forwarded_event = forwarded[0]["events"][0] + assert forwarded_event["project_token"] == "dbundle_proj_test" + + +def test_connected_durable_mode_retains_spool_file_when_forwarding_fails(tmp_path: Path) -> None: + spool_dir = tmp_path / "spool" + + def forward_transport(_: dict[str, object]) -> object: + return type("Response", (), {"status_code": 500, "retry_after_ms": None})() + + handler = BrowserRelayHandler( + allowed_origins=["https://example.com"], + project_mode="connected", + project_token="dbundle_proj_test", + endpoint="https://api.debugbundle.com/v1/events", + spool_dir=str(spool_dir), + forward_transport=forward_transport, + ) + + response = handler.handle(_make_request()) + + assert response.status == 202 + spool_files = list(spool_dir.glob("*.events.json")) + assert len(spool_files) == 1 + assert not spool_files[0].with_name(f"{spool_files[0].name}.delivered").exists() + + +def test_connected_low_latency_mode_returns_500_when_forwarding_fails() -> None: + def forward_transport(_: dict[str, object]) -> object: + return type("Response", (), {"status_code": 500, "retry_after_ms": None})() + + handler = BrowserRelayHandler( + allowed_origins=["https://example.com"], + project_mode="connected", + project_token="dbundle_proj_test", + endpoint="https://api.debugbundle.com/v1/events", + durable_write=False, + forward_transport=forward_transport, + ) + + response = handler.handle(_make_request()) + + assert response.status == 500 From f5697a22984bdab3698b2507bf14c43c98704bdf Mon Sep 17 00:00:00 2001 From: Owen Far Date: Tue, 19 May 2026 19:12:19 +0200 Subject: [PATCH 3/3] test: cover python relay delivery release paths --- tests/test_relay_delivery.py | 111 +++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/test_relay_delivery.py diff --git a/tests/test_relay_delivery.py b/tests/test_relay_delivery.py new file mode 100644 index 0000000..2b9e722 --- /dev/null +++ b/tests/test_relay_delivery.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +import debugbundle.relay_delivery as relay_delivery + + +def test_resolves_default_delivery_directories_and_attaches_project_token( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + + assert relay_delivery.resolve_default_local_events_dir() == str(tmp_path / ".debugbundle" / "local" / "events") + assert relay_delivery.resolve_default_relay_spool_dir() == str( + tmp_path / ".debugbundle" / "local" / "browser-relay-spool" + ) + assert relay_delivery.attach_project_token([{"event_id": "evt-1"}], "dbundle_proj_test") == [ + {"event_id": "evt-1", "project_token": "dbundle_proj_test"} + ] + + +def test_mark_spool_file_delivered_creates_marker_file(tmp_path: Path) -> None: + written_file = tmp_path / "spool" / "batch.events.json" + written_file.parent.mkdir(parents=True) + written_file.write_text("[]", encoding="utf-8") + + relay_delivery.mark_spool_file_delivered(str(written_file)) + + assert written_file.with_name(f"{written_file.name}{relay_delivery.RELAY_SPOOL_DELIVERED_MARKER_SUFFIX}").exists() + + +def test_mark_spool_file_delivered_ignores_marker_write_failures(tmp_path: Path) -> None: + relay_delivery.mark_spool_file_delivered(str(tmp_path / "missing" / "batch.events.json")) + + +def test_atomic_relay_file_transport_handles_empty_batches() -> None: + transport = relay_delivery.AtomicRelayFileTransport("/tmp/debugbundle-events", "web api") + + result = transport.write([]) + + assert result.status_code == 202 + assert result.written_file_path is None + + +def test_atomic_relay_file_transport_returns_500_when_writes_fail( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + transport = relay_delivery.AtomicRelayFileTransport(str(tmp_path / "events"), "web api") + + def fail_write(_: str, __: str) -> None: + raise OSError("disk full") + + monkeypatch.setattr(relay_delivery, "_write_secure_temp_file", fail_write) + + result = transport.write([{"event_id": "evt-1"}]) + + assert result.status_code == 500 + assert result.written_file_path is None + + +def test_relay_forward_transport_handles_missing_token_and_transport_errors() -> None: + def raising_transport(_: dict[str, object]) -> object: + raise RuntimeError("boom") + + transport = relay_delivery.RelayForwardTransport( + "https://api.debugbundle.test/v1/events", transport=raising_transport + ) + + assert transport.send("", [{"event_id": "evt-1"}]) == (False, False) + assert transport.send("dbundle_proj_test", [{"event_id": "evt-1"}]) == (True, False) + + +def test_assert_not_symlink_rejects_symbolic_links(tmp_path: Path) -> None: + target = tmp_path / "target.events.json" + target.write_text("[]", encoding="utf-8") + symlink_path = tmp_path / "symlink.events.json" + symlink_path.symlink_to(target) + + with pytest.raises(OSError, match="symlink_path_rejected"): + relay_delivery._assert_not_symlink(str(symlink_path)) + + +def test_cleanup_temp_files_ignores_missing_directories_and_remove_failures( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + events_dir = tmp_path / "events" + events_dir.mkdir() + stale_tmp_file = events_dir / "stale.tmp-1" + stale_tmp_file.write_text("stale", encoding="utf-8") + blocked_tmp_file = events_dir / "blocked.tmp-2" + blocked_tmp_file.write_text("blocked", encoding="utf-8") + keep_file = events_dir / "keep.events.json" + keep_file.write_text("keep", encoding="utf-8") + + original_remove = relay_delivery.os.remove + + def flaky_remove(path: str) -> None: + if path.endswith(blocked_tmp_file.name): + raise OSError("busy") + original_remove(path) + + monkeypatch.setattr(relay_delivery.os, "remove", flaky_remove) + + relay_delivery._cleanup_temp_files(str(events_dir)) + relay_delivery._cleanup_temp_files(str(tmp_path / "missing")) + + assert not stale_tmp_file.exists() + assert blocked_tmp_file.exists() + assert keep_file.exists() \ No newline at end of file