diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53d542b..72f0315 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de55837..2302611 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,10 +19,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" @@ -122,7 +122,7 @@ jobs: run: python -m twine check dist/* - name: Create GitHub release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: v${{ steps.release_version.outputs.value }} files: dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md index da54cc9..72812b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +## [1.1.0] - 2026-06-08 + +### Added +- Added path-scoped immediate client-error incident promotion support in remote capture-policy parsing so explicitly configured `4xx` routes can emit standalone `request_event` incident signals without widening the status globally. + +### Changed +- Unpromoted client-error request telemetry now remains context-only under repeated traffic, while `5xx` handling and explicitly promoted client-error behavior are preserved. + ## [1.0.0] - 2026-05-31 ### Changed diff --git a/pyproject.toml b/pyproject.toml index 6826271..ad06212 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "debugbundle-python" -version = "1.0.0" +version = "1.1.0" 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 7c9488f..5cc57c1 100644 --- a/src/debugbundle/config.py +++ b/src/debugbundle/config.py @@ -6,6 +6,13 @@ DEFAULT_PROBES_POLL_INTERVAL_MS = 60_000 +@dataclass(frozen=True) +class ImmediateClientErrorPathRule: + status_code: int + path_pattern: str + methods: tuple[str, ...] + + @dataclass(frozen=True) class CapturePolicy: preset: str @@ -14,6 +21,7 @@ class CapturePolicy: capture_breadcrumbs: str capture_probe_events: str immediate_client_error_statuses: tuple[int, ...] + immediate_client_error_path_rules: tuple[ImmediateClientErrorPathRule, ...] = () @dataclass(frozen=True) @@ -42,6 +50,7 @@ class RemoteConfigSnapshot: capture_breadcrumbs="exception_only", capture_probe_events="buffer_only", immediate_client_error_statuses=(), + immediate_client_error_path_rules=(), ) MINIMAL_CAPTURE_POLICY = CapturePolicy( @@ -51,6 +60,7 @@ class RemoteConfigSnapshot: capture_breadcrumbs="local_only", capture_probe_events="buffer_only", immediate_client_error_statuses=(), + immediate_client_error_path_rules=(), ) @@ -123,6 +133,9 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None: immediate_client_error_statuses = _parse_immediate_client_error_statuses( payload.get("immediate_client_error_statuses") ) + immediate_client_error_path_rules = _parse_immediate_client_error_path_rules( + payload.get("immediate_client_error_path_rules") + ) if capture_logs not in {"off", "error", "warning", "info"}: return None @@ -132,7 +145,7 @@ 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: + if immediate_client_error_statuses is None or immediate_client_error_path_rules is None: return None return CapturePolicy( @@ -142,6 +155,7 @@ def _parse_capture_policy(payload: object) -> CapturePolicy | None: capture_breadcrumbs=capture_breadcrumbs, capture_probe_events=capture_probe_events, immediate_client_error_statuses=immediate_client_error_statuses, + immediate_client_error_path_rules=immediate_client_error_path_rules, ) @@ -160,6 +174,56 @@ def _parse_immediate_client_error_statuses(value: object) -> tuple[int, ...] | N return tuple(sorted(set(statuses))) +def _parse_immediate_client_error_path_rules(value: object) -> tuple[ImmediateClientErrorPathRule, ...] | None: + if value is None: + return () + if not isinstance(value, list) or len(value) > 25: + return None + + rules: list[ImmediateClientErrorPathRule] = [] + for item in value: + if not isinstance(item, dict): + return None + status_code = item.get("status_code") + path_pattern = item.get("path_pattern") + raw_methods = item.get("methods", []) + if ( + not isinstance(status_code, int) + or isinstance(status_code, bool) + or status_code < 400 + or status_code > 499 + or not isinstance(path_pattern, str) + or not _is_valid_path_pattern(path_pattern) + or not isinstance(raw_methods, list) + or len(raw_methods) > 7 + ): + return None + + methods: list[str] = [] + for raw_method in raw_methods: + method = raw_method.upper() if isinstance(raw_method, str) else "" + if method not in {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}: + return None + if method not in methods: + methods.append(method) + rules.append( + ImmediateClientErrorPathRule( + status_code=status_code, + path_pattern=path_pattern, + methods=tuple(methods), + ) + ) + + return tuple(rules) + + +def _is_valid_path_pattern(value: str) -> bool: + if not value.startswith("/") or len(value) == 0 or len(value) > 256 or "?" in value or "#" in value: + return False + wildcard_index = value.find("*") + return wildcard_index == -1 or wildcard_index == len(value) - 1 + + 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 d0ea21d..66ddd6d 100644 --- a/src/debugbundle/core.py +++ b/src/debugbundle/core.py @@ -55,9 +55,6 @@ } BALANCED_IMMEDIATE_REQUEST_STATUSES = {408, 423, 424, 425, 429} INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES = BALANCED_IMMEDIATE_REQUEST_STATUSES | {409} -BALANCED_STANDARD_ANOMALY_STATUSES = {401, 403, 404, 409, 422} -BALANCED_HIGH_VOLUME_ANOMALY_STATUSES = {400, 410} -INVESTIGATIVE_ANOMALY_STATUSES = BALANCED_STANDARD_ANOMALY_STATUSES | BALANCED_HIGH_VOLUME_ANOMALY_STATUSES @dataclass @@ -286,7 +283,12 @@ def capture_request( context: Mapping[str, object] | None = None, ) -> None: with self._lock: - if not self._enabled or not self._passes_sample_rate() or not self._should_capture_request_event(response): + should_capture = ( + self._enabled + and self._passes_sample_rate() + and self._should_capture_request_event(request, response) + ) + if not should_capture: return payload = _request_event_payload( _redact_mapping(dict(request), self._redact_fields), @@ -602,17 +604,31 @@ def _effective_log_threshold(self) -> str: policy_threshold = self._capture_policy.capture_logs return self._log_level if LEVEL_RANKS[self._log_level] >= LEVEL_RANKS[policy_threshold] else policy_threshold - def _should_capture_request_event(self, response: Mapping[str, object] | None) -> bool: + def _should_capture_request_event( + self, + request: Mapping[str, object] | None, + response: Mapping[str, object] | None, + ) -> bool: policy = self._capture_policy.capture_request_events status_code = None if response is not None: candidate = response.get("status_code") or response.get("response_status") if isinstance(candidate, int): status_code = candidate + request_path = None + http_method = None + if request is not None: + path_candidate = request.get("path") or request.get("url") + method_candidate = request.get("method") + request_path = path_candidate if isinstance(path_candidate, str) else None + http_method = method_candidate if isinstance(method_candidate, str) else None if _is_immediate_request_incident_status( status_code, self._capture_policy.preset, self._capture_policy.immediate_client_error_statuses, + request_path, + http_method, + self._capture_policy.immediate_client_error_path_rules, ): return True if policy == "off": @@ -624,7 +640,7 @@ def _should_capture_request_event(self, response: Mapping[str, object] | None) - if status_code is None: return policy == "filtered" if policy == "failures_only": - return _is_request_anomaly_candidate_status(status_code, self._capture_policy.preset) + return status_code >= 500 if policy == "filtered": return False return True @@ -859,6 +875,9 @@ def _is_immediate_request_incident_status( status_code: int | None, preset: str, immediate_client_error_statuses: tuple[int, ...], + request_path: str | None = None, + http_method: str | None = None, + immediate_client_error_path_rules: tuple[object, ...] = (), ) -> bool: if status_code is None: return False @@ -866,6 +885,13 @@ def _is_immediate_request_incident_status( return True if status_code in immediate_client_error_statuses: return True + if _matches_immediate_client_error_path_rule( + status_code, + request_path, + http_method, + immediate_client_error_path_rules, + ): + return True if preset == "investigative": return status_code in INVESTIGATIVE_IMMEDIATE_REQUEST_STATUSES if preset == "balanced": @@ -873,16 +899,41 @@ def _is_immediate_request_incident_status( return False -def _is_request_anomaly_candidate_status(status_code: int | None, preset: str) -> bool: - if status_code is None or status_code < 400 or status_code >= 500: +def _matches_immediate_client_error_path_rule( + status_code: int, + request_path: str | None, + http_method: str | None, + rules: tuple[object, ...], +) -> bool: + if status_code < 400 or status_code > 499 or request_path is None: return False - if preset == "investigative": - return status_code in INVESTIGATIVE_ANOMALY_STATUSES - if preset == "balanced": - return status_code in BALANCED_STANDARD_ANOMALY_STATUSES or status_code in BALANCED_HIGH_VOLUME_ANOMALY_STATUSES + normalized_path = _normalize_request_path(request_path) + normalized_method = http_method.upper() if isinstance(http_method, str) else None + for rule in rules: + rule_status = getattr(rule, "status_code", None) + path_pattern = getattr(rule, "path_pattern", None) + methods = getattr(rule, "methods", ()) + if rule_status != status_code or not isinstance(path_pattern, str): + continue + if methods and (normalized_method is None or normalized_method not in methods): + continue + if path_pattern.endswith("*"): + if normalized_path.startswith(path_pattern[:-1]): + return True + elif normalized_path == path_pattern: + return True return False +def _normalize_request_path(value: str) -> str: + from urllib.parse import urlparse + + parsed = urlparse(value) + if parsed.path: + return parsed.path + return value.split("?", 1)[0].split("#", 1)[0] if value.startswith("/") else "/" + + def _time_now() -> float: return datetime.now(tz=timezone.utc).timestamp() @@ -891,7 +942,7 @@ def _sdk_version() -> str: try: return metadata.version("debugbundle-python") except metadata.PackageNotFoundError: - return "1.0.0" + return "1.1.0" def _sdk_config_endpoint(events_endpoint: str) -> str: diff --git a/tests/test_remote_config.py b/tests/test_remote_config.py index 5608124..d9942d9 100644 --- a/tests/test_remote_config.py +++ b/tests/test_remote_config.py @@ -229,7 +229,7 @@ def test_capture_policy_filters_logs_and_request_events_from_remote_config() -> assert events[1]["payload"]["response_status"] == 503 -def test_balanced_capture_policy_keeps_request_failure_anomaly_candidates() -> None: +def test_balanced_capture_policy_keeps_immediate_failures_but_not_unconfigured_4xx() -> None: clock = ManualClock() fetch = FakeFetch( responses=[ @@ -271,7 +271,53 @@ def test_balanced_capture_policy_keeps_request_failure_anomaly_candidates() -> N for event in events if event["event_type"] == "request_event" ] - assert request_statuses == [429, 404, 409] + assert request_statuses == [429] + + +def test_capture_policy_promotes_configured_client_error_path_rules_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_path_rules": [ + {"status_code": 404, "path_pattern": "/checkout/*", "methods": ["POST"]} + ], + }, + }, + ) + ] + ) + 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/cart", "headers": {}}, {"status_code": 404}) + sdk.capture_request({"method": "GET", "path": "/checkout/cart", "headers": {}}, {"status_code": 404}) + sdk.capture_request({"method": "POST", "path": "/robots.txt", "headers": {}}, {"status_code": 404}) + sdk.flush() + + events = _events(transport) + request_events = [event for event in events if event["event_type"] == "request_event"] + assert len(request_events) == 1 + assert request_events[0]["payload"]["path"] == "/checkout/cart" + assert request_events[0]["payload"]["response_status"] == 404 def test_investigative_capture_policy_promotes_409_even_when_request_capture_is_off() -> None: diff --git a/tests/test_repository_metadata.py b/tests/test_repository_metadata.py index f93a8f1..a3a2bbe 100644 --- a/tests/test_repository_metadata.py +++ b/tests/test_repository_metadata.py @@ -30,14 +30,14 @@ def test_standalone_changelog_and_security_policy_are_launch_ready() -> None: security = (REPO_ROOT / "SECURITY.md").read_text(encoding="utf-8") assert "## [Unreleased]" in changelog - assert "## [1.0.0] - 2026-05-31" in changelog + assert "## [1.1.0] - 2026-06-08" in changelog assert "https://github.com/debugbundle/debugbundle-python/security/advisories/new" in security def test_standalone_ci_workflow_covers_python_sdk_checks() -> None: workflow = (REPO_ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") - assert "actions/setup-python@v5" in workflow + assert "actions/setup-python@v6" in workflow assert 'python-version: "3.12"' in workflow assert "python -m pip install -e .[dev]" in workflow assert "ruff check src tests" in workflow diff --git a/tests/test_smoke_script.py b/tests/test_smoke_script.py index 58db66d..2468559 100644 --- a/tests/test_smoke_script.py +++ b/tests/test_smoke_script.py @@ -32,7 +32,7 @@ def fake_run_subprocess(command: list[str]) -> None: monkeypatch.setattr(SMOKE.time, "sleep", sleeps.append) SMOKE._install_with_retry( - ["python", "-m", "pip", "install", "debugbundle-python==1.0.0"], + ["python", "-m", "pip", "install", "debugbundle-python==1.1.0"], retries=3, retry_delay_seconds=7, ) @@ -50,7 +50,7 @@ def fake_run_subprocess(command: list[str]) -> None: try: SMOKE._install_with_retry( - ["python", "-m", "pip", "install", "debugbundle-python==1.0.0"], + ["python", "-m", "pip", "install", "debugbundle-python==1.1.0"], retries=2, retry_delay_seconds=1, )