From e2701d47f48d2f19efe26f8720368ea5c4f06455 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 8 May 2026 11:53:09 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat!:=20v8.0=20=E2=80=94=20decision=20hist?= =?UTF-8?q?ory=20API=20+=20telemetry=20simplification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major release. The headline customer-facing feature is the list_decisions client API for paging recorded decisions; the v8 line also tightens the telemetry contract. Telemetry simplification: - Drop the AxonFlowConfig.telemetry field and the corresponding AxonFlow(..., telemetry=...) keyword argument. AXONFLOW_TELEMETRY=off in the environment is now the SOLE opt-out lever. - Drop the mode != "sandbox" default-suppression rule. Sandbox-mode clients now fire telemetry on the same schedule as production-mode clients; the payload is tagged stream="sandbox" so analytics can distinguish dev/test pings from production heartbeat without conflating them. The wire allowlist is enforced server-side by the checkpoint-service IsValidIncomingStream gate (axonflow-enterprise PR #2005). - Simplify _is_telemetry_enabled() to a single env-var check. Drop mode / telemetry_enabled / has_credentials parameters from the internal helpers (_is_telemetry_enabled, send_telemetry_ping, maybe_send_heartbeat). - Add stream field to telemetry payload via _build_payload — set to "sandbox" only when mode == "sandbox", omitted otherwise (server defaults absent stream to "heartbeat", preserving wire-shape parity with v7.x for the production case). Tests: - test_telemetry.py rewritten under v8 contract: drops the config-override matrix, adds stream-tag assertions, uses monkeypatch.setenv("AXONFLOW_TELEMETRY", "") to clear the conftest-level autouse opt-out for tests that need to exercise the firing path. - test_heartbeat.py / test_heartbeat_e2e.py: drop telemetry_enabled=True from maybe_send_heartbeat call sites — the parameter no longer exists. Runtime-e2e: - Adds runtime-e2e/sandbox_telemetry_stream_tag/{test.sh,README.md} that builds a tiny Python program against the local SDK in sandbox mode and asserts the CloudWatch event_stored row carries sdk=python/8 AND stream=sandbox. NOT YET EXECUTED — gated on axonflow-enterprise PR #2005 deploy. Without #2005 the server hardcodes stream=heartbeat regardless of payload. Mirrors axonflow-sdk-go PR #160. Linked: axonflow-sdk-typescript PR #2006 (TS counterpart), axonflow-enterprise PR #2005 (server-side wire allowlist). Migration: see CHANGELOG.md "Migration guide (v7 → v8)" section. Signed-off-by: Saurabh Jain --- CHANGELOG.md | 52 +++ README.md | 23 ++ axonflow/_version.py | 2 +- axonflow/client.py | 14 +- axonflow/heartbeat.py | 14 +- axonflow/telemetry.py | 66 ++-- axonflow/types.py | 13 +- pyproject.toml | 2 +- .../sandbox_telemetry_stream_tag/README.md | 45 +++ .../sandbox_telemetry_stream_tag/test.sh | 112 +++++++ tests/test_heartbeat.py | 28 +- tests/test_heartbeat_e2e.py | 10 +- tests/test_telemetry.py | 308 +++++++++--------- 13 files changed, 467 insertions(+), 222 deletions(-) create mode 100644 runtime-e2e/sandbox_telemetry_stream_tag/README.md create mode 100755 runtime-e2e/sandbox_telemetry_stream_tag/test.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f9eb1f..eed707a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and tag v{X.Y.Z}. The release workflow's preflight checks the section header matches the tag. --> +## [8.0.0] - 2026-05-08 — Decision history API + telemetry simplification + +**Major release.** The headline feature is the new decision-history client API: +`list_decisions` for paging through recorded decisions, alongside the +`get_decision_explain` method shipped in v7.4.0 — callers can now both list +and drill in. Bundled into a major because the v8 line also tightens the +telemetry contract — see `Removed` at the bottom of this entry for that. + +### Added + +- **`client.list_decisions(opts)` method.** Pages over recorded decision + history from the orchestrator, mirroring `GET /api/v1/decisions`. + Companion to the v7.4.0 `get_decision_explain` method — callers can + now both list and drill in. Already shipped on `main` via PR #186 and + graduated into the v8.0 line with this release. See type + `ListDecisionsOptions` and `DecisionListItem` in `axonflow.decisions`. + +### Migration guide (v7 → v8) + +- **`AxonFlow(...)` no longer accepts the `telemetry` keyword argument.** + Code passing `AxonFlow(..., telemetry=True)` or + `AxonFlow(..., telemetry=False)` will raise `TypeError` at construction + time. Migration: + - If you were using it to disable telemetry, set + `AXONFLOW_TELEMETRY=off` in the environment instead — that's the + sole opt-out lever as of v8.0. + - If you were using it to force-enable, the default is now ON for + every mode so the argument is no longer needed. +- **`AxonFlowConfig.telemetry` field removed.** Code that constructed + the dataclass directly with `AxonFlowConfig(..., telemetry=...)` will + fail to instantiate. Drop the field; rely on the env var. + +### Removed + +- **`AxonFlow(..., telemetry=...)` keyword argument** and the + corresponding `AxonFlowConfig.telemetry: bool | None` field. + `AXONFLOW_TELEMETRY=off` is now the sole opt-out path. Tests that + need to defend against contaminated dev environments should clear + the env var explicitly via `monkeypatch.setenv("AXONFLOW_TELEMETRY", "")`. +- **Sandbox-mode silent telemetry suppression.** Sandbox-mode clients + (constructed via `AxonFlow.sandbox()` or `mode=Mode.SANDBOX`) now fire + telemetry on the same heartbeat schedule as production-mode clients. + Pings are tagged `stream="sandbox"` so analytics can distinguish dev + pings from production heartbeat — see the checkpoint-service + `IsValidIncomingStream` allowlist for the wire-side gate. +- **`send_telemetry_ping` signature change.** The internal helper + `axonflow.telemetry.send_telemetry_ping` no longer accepts + `telemetry_enabled` or `has_credentials` parameters; `_is_telemetry_enabled` + takes no arguments. Callers should not have been depending on these + internals (they're underscore-prefixed), but the change is recorded + here for completeness. + ## [7.1.0] - 2026-05-06 — X-Axonflow-Client header + scope-aware license validation **Companion release to platform v7.7.0.** The Python SDK now sends an diff --git a/README.md b/README.md index 8dad9d5..f2c6389 100644 --- a/README.md +++ b/README.md @@ -441,11 +441,34 @@ If you are evaluating AxonFlow in a company setting and cannot open a public iss No email required. Optional contact if you want a response. +## Sandbox Mode + +```python +# Quick sandbox client for local testing — defaults to http://localhost:8080. +from axonflow import AxonFlow + +client = AxonFlow.sandbox() +``` + +> Sandbox-mode clients fire telemetry like every other client — anonymous SDK +> heartbeat, classification-only payload, opt-out via `AXONFLOW_TELEMETRY=off`. +> Pings are tagged `stream="sandbox"` server-side so dev/test usage is +> distinguishable from production heartbeat. (Pre-v8.0 sandbox-mode pings +> were silently suppressed; the suppression was removed in v8.0 to give a +> single ops-controlled opt-out lever.) + ## Telemetry This SDK sends anonymous usage telemetry (SDK version, OS, enabled features) to help improve AxonFlow. No prompts, payloads, or PII are ever collected. Opt out: `AXONFLOW_TELEMETRY=off`. +`AXONFLOW_TELEMETRY=off` is the **sole opt-out lever** as of v8.0. The +v7.x `telemetry` keyword argument on `AxonFlow(...)` and the +corresponding `AxonFlowConfig.telemetry` field have been removed; the +previous silent suppression of sandbox-mode pings has also been removed +(sandbox-mode pings now fire and are tagged `stream="sandbox"` so +they're distinguishable from production heartbeat). + ### Scope of `AXONFLOW_TELEMETRY=off` `AXONFLOW_TELEMETRY=off` disables the anonymous SDK heartbeat (version, OS, architecture). On **self-hosted** and **in-VPC** deployments, that heartbeat is the only data the SDK sends to AxonFlow, so setting `=off` means we receive nothing. On **Community SaaS** (`try.getaxonflow.com`) the hosted service also processes operational data — registrations, audit logs, policy enforcement records, workflow state, plan data, and request-header metadata aggregated for usage analytics — as part of running the platform; that operational data flow is governed by the [Privacy Policy](https://getaxonflow.com/privacy/), not by `AXONFLOW_TELEMETRY`. diff --git a/axonflow/_version.py b/axonflow/_version.py index efc3e86..fff09e2 100644 --- a/axonflow/_version.py +++ b/axonflow/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the AxonFlow SDK version.""" -__version__ = "7.1.0" +__version__ = "8.0.0" diff --git a/axonflow/client.py b/axonflow/client.py index b1697fc..c24787a 100644 --- a/axonflow/client.py +++ b/axonflow/client.py @@ -477,7 +477,6 @@ def __init__( *, mode: Mode | str = Mode.PRODUCTION, debug: bool = False, - telemetry: bool | None = None, timeout: float = 60.0, map_timeout: float = 120.0, insecure_skip_verify: bool = False, @@ -492,11 +491,10 @@ def __init__( endpoint: AxonFlow endpoint URL. Can also be set via AXONFLOW_AGENT_URL env var. client_id: Client ID (optional for community/self-hosted mode) client_secret: Client secret (optional for community/self-hosted mode) - mode: Operation mode (production or sandbox) + mode: Operation mode (production or sandbox). Sandbox-mode no longer + suppresses telemetry as of v8.0 — pings fire and are tagged + ``stream="sandbox"`` server-side. debug: Enable debug logging - telemetry: Enable/disable anonymous telemetry. ``None`` uses mode default - (ON for production, OFF for sandbox). Set ``AXONFLOW_TELEMETRY=off`` - to opt out via environment. timeout: Request timeout in seconds map_timeout: Timeout for MAP operations in seconds (default: 120s) MAP operations involve multiple LLM calls and need longer timeouts @@ -614,11 +612,12 @@ def __init__( # (CLI, serverless cold-starts) still deliver the ping. Subsequent # gate runs happen async via ``_pre_request_hook`` on every # public HTTP request. See axonflow/heartbeat.py for the contract - # and stamp-on-DELIVERY semantics. + # and stamp-on-DELIVERY semantics. The v7.x ``telemetry_enabled`` + # programmatic override was removed in v8.0; AXONFLOW_TELEMETRY=off + # is now the sole opt-out lever. maybe_send_heartbeat( mode=self._config.mode.value, endpoint=self._config.endpoint, - telemetry_enabled=telemetry, debug=debug, ) @@ -774,7 +773,6 @@ def _pre_request_hook(self) -> None: maybe_send_heartbeat( mode=self._config.mode.value, endpoint=self._config.endpoint, - telemetry_enabled=self._config.telemetry, debug=self._config.debug, ) diff --git a/axonflow/heartbeat.py b/axonflow/heartbeat.py index 4c8b89b..8b46c4a 100644 --- a/axonflow/heartbeat.py +++ b/axonflow/heartbeat.py @@ -8,8 +8,11 @@ The gate is consulted both at client construction and at every public HTTP request site (via ``_pre_request_hook``). Each gate run: -1. Re-evaluates ``AXONFLOW_TELEMETRY=off`` / mode-disabled cheaply - (lock-free) so a mid-process opt-out toggle takes effect immediately. +1. Re-evaluates ``AXONFLOW_TELEMETRY=off`` cheaply (lock-free) so a + mid-process opt-out toggle takes effect immediately. As of v8.0 the + env var is the sole opt-out path — sandbox-mode is no longer + silently suppressed; sandbox pings fire and carry stream="sandbox" + in the payload. 2. Checks an in-memory 1-hour cache to bound stat() syscall frequency on hot request paths. 3. Reads the stamp file mtime as the source of truth for last successful @@ -237,7 +240,6 @@ def _register_thread(t: threading.Thread) -> None: def maybe_send_heartbeat( mode: str, endpoint: str, - telemetry_enabled: bool | None, debug: bool = False, ) -> None: """Central gate for telemetry pings. @@ -245,12 +247,16 @@ def maybe_send_heartbeat( Called from ``AxonFlow.__init__`` and ``AxonFlow._pre_request_hook``. Implements the contract documented at the top of this module. Never raises — heartbeat failures must not surface to the caller. + + The v7.x ``telemetry_enabled`` parameter was removed in v8.0 along + with the corresponding config field. ``AXONFLOW_TELEMETRY=off`` in + the environment is now the SOLE opt-out lever — see CHANGELOG. """ # Lazy imports break the heartbeat → telemetry → heartbeat cycle that # would otherwise occur if these were top-level imports. from axonflow.telemetry import _is_telemetry_enabled, _send_telemetry_ping_now # noqa: PLC0415 - if not _is_telemetry_enabled(mode, telemetry_enabled, has_credentials=False): + if not _is_telemetry_enabled(): return h = _state diff --git a/axonflow/telemetry.py b/axonflow/telemetry.py index 21caca9..da3fe47 100644 --- a/axonflow/telemetry.py +++ b/axonflow/telemetry.py @@ -64,34 +64,27 @@ def _flush_pending_telemetry() -> None: t.join(timeout=_TIMEOUT_SECONDS) -def _is_telemetry_enabled( - mode: str, - telemetry_enabled: bool | None, - has_credentials: bool, # noqa: ARG001 kept for API compat -) -> bool: +def _is_telemetry_enabled() -> bool: """Determine whether telemetry should fire. - Priority (highest to lowest): - 1. ``AXONFLOW_TELEMETRY=off`` environment variable -> disabled - (canonical AxonFlow-specific opt-out) - 2. Explicit config value (``telemetry_enabled``) -> use that - 3. Default: ON for all modes except sandbox + ``AXONFLOW_TELEMETRY=off`` in the environment is the SOLE opt-out path. + Telemetry is otherwise ON by default, regardless of mode (sandbox / + production / anything else). Sandbox-mode pings are tagged + ``stream="sandbox"`` in the payload so analytics can still distinguish + them — see ``_build_payload``. + + Historical context: v7.x supported a ``telemetry_enabled: bool | None`` + config field and a ``mode != "sandbox"`` default-suppression rule. + Both were removed in v8.0 to leave a single, ops-controlled opt-out + lever and avoid silent suppression that masks real adoption signal. + See CHANGELOG v8.0.0. ``DO_NOT_TRACK`` is intentionally NOT honored. It is commonly inherited from host tools and developer environments (CLIs like Codex and Claude Code inject it unconditionally), which makes it an unreliable expression of user intent for AxonFlow telemetry. """ - # Environment-level opt-out always wins. - if os.environ.get("AXONFLOW_TELEMETRY", "").strip().lower() == "off": - return False - - # Explicit config override. - if telemetry_enabled is not None: - return telemetry_enabled - - # Default: ON everywhere except sandbox mode. - return mode != "sandbox" + return os.environ.get("AXONFLOW_TELEMETRY", "").strip().lower() != "off" def _detect_platform_version(endpoint: str, timeout: float = 2.0) -> str | None: @@ -179,8 +172,17 @@ def _build_payload( platform_version: str | None = None, endpoint_type: str = "unknown", ) -> dict[str, object]: - """Build the JSON payload for the checkpoint ping.""" - return { + """Build the JSON payload for the checkpoint ping. + + The ``stream`` field classifies the heartbeat sub-stream. Sandbox-mode + clients emit ``"sandbox"`` so analytics can distinguish dev/test pings + from production heartbeat without conflating them; production-mode and + other modes omit the field entirely (we drop None-valued entries before + JSON-encoding) and the server defaults to ``"heartbeat"``. The + wire-allowlist is enforced server-side — see checkpoint-service + ``IsValidIncomingStream``. + """ + payload: dict[str, object] = { "sdk": "python", "sdk_version": _SDK_VERSION, "platform_version": platform_version, @@ -192,6 +194,9 @@ def _build_payload( "features": [], "instance_id": str(uuid.uuid4()), } + if mode == "sandbox": + payload["stream"] = "sandbox" + return payload def _send_telemetry_ping_now(url: str, mode: str, endpoint: str, debug: bool) -> bool: @@ -271,24 +276,25 @@ def _do_ping(url: str, mode: str, endpoint: str, debug: bool) -> None: def send_telemetry_ping( mode: str, endpoint: str, - telemetry_enabled: bool | None, - has_credentials: bool = False, debug: bool = False, ) -> None: """Fire-and-forget telemetry ping. Runs in a daemon thread. Args: mode: SDK operation mode (``"production"`` or ``"sandbox"``). + Sandbox-mode pings fire on the same schedule as production-mode + pings as of v8.0; the payload is tagged ``stream="sandbox"`` so + analytics can distinguish them server-side. endpoint: The AxonFlow agent endpoint, used to detect the platform version via ``/health``. - telemetry_enabled: Explicit config override. ``None`` means use the - mode-based default. - has_credentials: Whether the client was initialized with credentials - (clientId + clientSecret). Used to distinguish managed cloud from - self-hosted/community deployments for the default behavior. debug: When ``True``, log debug-level messages about the ping. + + Note: + ``AXONFLOW_TELEMETRY=off`` is the SOLE opt-out path. The v7.x + ``telemetry_enabled`` parameter and ``has_credentials`` parameter + were removed in v8.0 — see CHANGELOG. """ - if not _is_telemetry_enabled(mode, telemetry_enabled, has_credentials): + if not _is_telemetry_enabled(): return logger.info( diff --git a/axonflow/types.py b/axonflow/types.py index 75df084..073d3f7 100644 --- a/axonflow/types.py +++ b/axonflow/types.py @@ -65,6 +65,10 @@ class AxonFlowConfig(BaseModel): The SDK will work without authentication headers in this mode. As of v1.0.0, all routes go through a single endpoint (ADR-026). + + As of v8.0, the legacy ``telemetry`` field has been removed. To + opt out of the anonymous heartbeat, set ``AXONFLOW_TELEMETRY=off`` + in the environment — it is now the sole opt-out lever. """ model_config = ConfigDict(frozen=True) @@ -74,10 +78,11 @@ class AxonFlowConfig(BaseModel): client_secret: str | None = Field(default=None, description="Client secret (optional)") mode: Mode = Field(default=Mode.PRODUCTION, description="Operation mode") debug: bool = Field(default=False, description="Enable debug logging") - telemetry: bool | None = Field( - default=None, - description="Enable/disable anonymous telemetry (None = mode default)", - ) + # `telemetry` field removed in v8.0. AXONFLOW_TELEMETRY=off is now the + # SOLE opt-out path for the SDK heartbeat. Sandbox-mode pings are no + # longer suppressed; they fire and carry stream="sandbox" in the + # payload so analytics can distinguish dev/test pings server-side. + # See CHANGELOG v8.0.0 and axonflow.telemetry._is_telemetry_enabled. timeout: float = Field(default=60.0, gt=0, description="Request timeout (seconds)") map_timeout: float = Field(default=120.0, gt=0, description="MAP operations timeout (seconds)") insecure_skip_verify: bool = Field(default=False, description="Skip TLS verify") diff --git a/pyproject.toml b/pyproject.toml index 86ccf7e..dd48499 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "axonflow" -version = "7.1.0" +version = "8.0.0" description = "AxonFlow Python SDK - Enterprise AI Governance in 3 Lines of Code" readme = "README.md" license = {text = "MIT"} diff --git a/runtime-e2e/sandbox_telemetry_stream_tag/README.md b/runtime-e2e/sandbox_telemetry_stream_tag/README.md new file mode 100644 index 0000000..5d91f0a --- /dev/null +++ b/runtime-e2e/sandbox_telemetry_stream_tag/README.md @@ -0,0 +1,45 @@ +# Runtime proof — Sandbox-mode telemetry fires with stream=sandbox (v8) + +Verifies the v8 contract: a `mode=Mode.SANDBOX` client (or the +`AxonFlow.sandbox()` factory) produces an anonymous heartbeat ping that +lands in checkpoint DynamoDB with the row tagged `stream="sandbox"`. + +## When to run + +**Post-deploy verification.** Two infrastructure prerequisites: + +1. **`axonflow-enterprise` PR #2005 deployed** — without the server-side + wire-allowlist, the Lambda hardcodes `stream=heartbeat` regardless of + payload, and this test will fail at the assertion step. Confirm with: + ```sh + curl -sS -X POST -H 'Content-Type: application/json' \ + -d '{"sdk":"python","sdk_version":"8.0.0","stream":"community_saas_operational","instance_id":"x"}' \ + https://checkpoint.getaxonflow.com/v1/ping + # Expect HTTP 400 "invalid stream value" + ``` +2. **AWS credentials** with read on `/aws/lambda/prod-axonflow-checkpoint`. + +## Usage + +```sh +AWS_REGION=us-east-1 ./test.sh +``` + +## What it asserts + +1. Builds a tiny Python program against the local SDK via `pip install -e ..`. +2. The program constructs an `AxonFlow(mode=Mode.SANDBOX, ...)` client + pointing at an unreachable endpoint. The SDK fires the anonymous + heartbeat during construction (checkpoint POST is independent of the + agent endpoint, so the unreachable agent doesn't suppress telemetry). +3. The Lambda's CloudWatch audit log records an `event_stored` row with + `sdk=python/8` AND `stream=sandbox`. + +## Pre-v8 behavior (regression-guard context) + +In v7.x, `mode=Mode.SANDBOX` triggered a default suppression rule in +`_is_telemetry_enabled()` — sandbox-mode clients produced ZERO pings +unless `telemetry=True` was passed explicitly. The `telemetry` kwarg has +been removed in v8.0 and the suppression rule with it; sandbox-mode now +fires by default and tags the payload with `stream="sandbox"`. This +test guards against any future refactor restoring a mode-based gate. diff --git a/runtime-e2e/sandbox_telemetry_stream_tag/test.sh b/runtime-e2e/sandbox_telemetry_stream_tag/test.sh new file mode 100755 index 0000000..28e48bf --- /dev/null +++ b/runtime-e2e/sandbox_telemetry_stream_tag/test.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Runtime proof — Python SDK v8 sandbox-mode telemetry fires with stream=sandbox. +# +# Builds a tiny Python program that uses the LOCAL SDK (via pip install -e ..) +# in sandbox mode against an unreachable agent endpoint. The SDK fires its +# anonymous telemetry ping during AxonFlow() construction. We then query the +# deployed checkpoint Lambda's CloudWatch logs for the audit line that +# should record stream=sandbox in DynamoDB. +# +# Pre-v8 this test would have produced ZERO pings (sandbox-mode silent +# suppression). Post-v8 we expect exactly one ping with stream=sandbox. +# +# Stack-state assumptions: +# - axonflow-enterprise PR #2005 is deployed (server-side stream allowlist +# accepts and persists "sandbox" — without that, this row is stored +# as stream=heartbeat, defeating the test's purpose). +# - AWS credentials with read access on /aws/lambda/prod-axonflow-checkpoint. +# +# Usage: +# AWS_REGION=us-east-1 ./test.sh + +set -uo pipefail + +REGION=${AWS_REGION:-us-east-1} +LOG_GROUP=${LOG_GROUP:-/aws/lambda/prod-axonflow-checkpoint} +RUN_TAG=$(date -u +%s) +SDK_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" + +red() { printf '\033[31m%s\033[0m\n' "$*"; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } + +# Build a transient venv that imports the local SDK + creates a Sandbox-mode +# client. The unreachable :65530 endpoint is intentional — we only want the +# anonymous heartbeat to fire, not any platform call. +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +python3 -m venv "$WORK/venv" +# shellcheck source=/dev/null +. "$WORK/venv/bin/activate" +pip install --quiet --upgrade pip +pip install --quiet -e "$SDK_ROOT" + +cat > "$WORK/main.py" <<'EOF' +import os +import time +from datetime import datetime, timezone + +# Clear AXONFLOW_TELEMETRY so the ping fires; conftest-level autouse env +# is a pytest construct and doesn't apply to this standalone runtime test. +os.environ.pop("AXONFLOW_TELEMETRY", None) + +from axonflow import AxonFlow, Mode + +ts = datetime.now(timezone.utc).isoformat() +print(f"[{ts}] Constructing sandbox-mode client (unreachable agent)...") +client = AxonFlow( + endpoint="http://localhost:65530", + client_id="rt-test", + client_secret="rt-test", + mode=Mode.SANDBOX, +) +ts = datetime.now(timezone.utc).isoformat() +print(f"[{ts}] AxonFlow() returned. Sleeping 2s for inflight HTTP...") +time.sleep(2) +ts = datetime.now(timezone.utc).isoformat() +print(f"[{ts}] Done.") +EOF + +T0_MS=$(($(date -u +%s)*1000)) +echo "Run tag: $RUN_TAG" +echo "T0 (ms): $T0_MS" +echo + +python "$WORK/main.py" 2>&1 || { + red "FAIL: subprocess errored before completing" + exit 1 +} + +echo +echo "Waiting 10s for CloudWatch log delivery..." +sleep 10 + +# Look for the audit row our run produced — match by sdk=python/8 against +# logs since T0. +echo "Querying CloudWatch logs since T0 for sdk=python/8 event_stored entries..." +HITS=$(aws --region "$REGION" logs filter-log-events \ + --log-group-name "$LOG_GROUP" \ + --start-time "$T0_MS" \ + --filter-pattern '"event_stored" "sdk=python/8"' \ + --query 'events[*].message' \ + --output text 2>&1) + +if [ -z "$HITS" ]; then + red "FAIL: no event_stored sdk=python/8 row landed in checkpoint logs since T0" + red " Expected: one audit row tagged stream=sandbox" + red " CloudWatch query window: $T0_MS → now" + exit 1 +fi + +echo "Audit rows found:" +echo "$HITS" +echo + +if echo "$HITS" | grep -q 'stream=sandbox'; then + green "PASS: Python SDK sandbox-mode ping landed with stream=sandbox" +else + red "FAIL: audit row did not include stream=sandbox" + red " This usually means PR #2005 (server-side allowlist) is not yet deployed —" + red " the server still hardcodes stream=heartbeat regardless of payload." + exit 1 +fi diff --git a/tests/test_heartbeat.py b/tests/test_heartbeat.py index 84e5877..1520d9d 100644 --- a/tests/test_heartbeat.py +++ b/tests/test_heartbeat.py @@ -89,7 +89,7 @@ def telemetry_enabled_env(monkeypatch): def test_cold_start_no_stamp_fires_once(isolated_state, mock_ping_success, telemetry_enabled_env): """Case 1: cold start, no stamp → 1 ping, stamp written.""" - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert mock_ping_success.call_count == 1 @@ -103,7 +103,7 @@ def test_fresh_stamp_does_not_fire(isolated_state, mock_ping_success, telemetry_ one_day_ago = __import__("time").time() - 24 * 3600 os.utime(isolated_state.stamp_path, (one_day_ago, one_day_ago)) - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert mock_ping_success.call_count == 0 @@ -118,7 +118,7 @@ def test_stale_stamp_fires_and_updates(isolated_state, mock_ping_success, teleme eight_days_ago = _time.time() - 8 * 24 * 3600 os.utime(isolated_state.stamp_path, (eight_days_ago, eight_days_ago)) - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert mock_ping_success.call_count == 1 @@ -129,7 +129,7 @@ def test_stale_stamp_fires_and_updates(isolated_state, mock_ping_success, teleme def test_rate_limit_within_1h_fires_once(isolated_state, mock_ping_success, telemetry_enabled_env): """Case 4: 5 calls within the 1h in-memory cache → exactly 1 ping.""" for _ in range(5): - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert mock_ping_success.call_count == 1 @@ -142,7 +142,7 @@ def test_after_rate_limit_expiry_fires_again( import time as _time # First call: ping fires, stamp written. - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert mock_ping_success.call_count == 1 @@ -152,7 +152,7 @@ def test_after_rate_limit_expiry_fires_again( eight_days_ago = _time.time() - 8 * 24 * 3600 os.utime(isolated_state.stamp_path, (eight_days_ago, eight_days_ago)) - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert mock_ping_success.call_count == 2 @@ -164,7 +164,7 @@ def test_opt_out_mid_process_stops_pings( """Case 6: AXONFLOW_TELEMETRY=off after first ping → 0 further pings, stamp unchanged.""" import time as _time - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert mock_ping_success.call_count == 1 @@ -177,7 +177,7 @@ def test_opt_out_mid_process_stops_pings( os.utime(isolated_state.stamp_path, (eight_days_ago, eight_days_ago)) mtime_before = isolated_state.stamp_path.stat().st_mtime - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert mock_ping_success.call_count == 1, "opt-out should suppress 2nd ping" @@ -193,7 +193,7 @@ def test_concurrent_callers_coalesce_to_one_ping( def runner(): barrier.wait() - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") threads = [threading.Thread(target=runner) for _ in range(100)] for t in threads: @@ -214,12 +214,12 @@ def test_no_cache_dir_pings_but_no_stamp(mock_ping_success, telemetry_enabled_en previous = replace_heartbeat_state_for_test(None) try: state = hb_module._state # noqa: SLF001 — the freshly-installed singleton - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert mock_ping_success.call_count == 1, "1st ping must fire even without cache dir" # 1h cache holds within the same process even without a stamp file. - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert mock_ping_success.call_count == 1, "in-memory cache must still suppress 2nd call" @@ -228,7 +228,7 @@ def test_no_cache_dir_pings_but_no_stamp(mock_ping_success, telemetry_enabled_en with state._lock: # noqa: SLF001 state._last_checked_monotonic = _time.monotonic() - 2 * 3600 # noqa: SLF001 - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert mock_ping_success.call_count == 2, ( "ping fires again when cache expires and no stamp exists" @@ -239,7 +239,7 @@ def test_no_cache_dir_pings_but_no_stamp(mock_ping_success, telemetry_enabled_en def test_ping_failure_stamp_not_written(isolated_state, mock_ping_failure, telemetry_enabled_env): """Case 9: ping returns False → stamp NOT written; retry on success works.""" - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert mock_ping_failure.call_count == 1 assert not isolated_state.stamp_path.exists(), "failed POST must not write stamp" @@ -252,7 +252,7 @@ def test_ping_failure_stamp_not_written(isolated_state, mock_ping_failure, telem success_mock = MagicMock(return_value=True) with patch("axonflow.telemetry._send_telemetry_ping_now", success_mock): - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert success_mock.call_count == 1 diff --git a/tests/test_heartbeat_e2e.py b/tests/test_heartbeat_e2e.py index 8fea26a..4fbc97d 100644 --- a/tests/test_heartbeat_e2e.py +++ b/tests/test_heartbeat_e2e.py @@ -137,7 +137,7 @@ def test_four_run_cycle_real_http(stamp_path: Path, telemetry_enabled_env, http_ # ---- Run 1: cold start, no stamp ---------------------------------- _reset_hits(200) _swap_state(stamp_path) - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert _hits() == 1, f"Run 1: expected 1 ping on cold start, got {_hits()}" @@ -146,7 +146,7 @@ def test_four_run_cycle_real_http(stamp_path: Path, telemetry_enabled_env, http_ # ---- Run 2: simulate fresh process — fresh state, same stamp file - _reset_hits(200) _swap_state(stamp_path) - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert _hits() == 0, f"Run 2: fresh stamp must suppress ping, got {_hits()}" @@ -157,7 +157,7 @@ def test_four_run_cycle_real_http(stamp_path: Path, telemetry_enabled_env, http_ _reset_hits(200) _swap_state(stamp_path) - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert _hits() == 1, f"Run 3: stale stamp must trigger a fresh ping, got {_hits()}" @@ -170,7 +170,7 @@ def test_four_run_cycle_real_http(stamp_path: Path, telemetry_enabled_env, http_ _reset_hits(503) _swap_state(stamp_path) - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert _hits() == 1, f"Run 4a: ping must be attempted under stale stamp, got {_hits()}" @@ -182,7 +182,7 @@ def test_four_run_cycle_real_http(stamp_path: Path, telemetry_enabled_env, http_ # ---- Run 4b: retry against the same server, now returning 200 ----- _reset_hits(200) _swap_state(stamp_path) - maybe_send_heartbeat(mode="production", endpoint="http://localhost", telemetry_enabled=True) + maybe_send_heartbeat(mode="production", endpoint="http://localhost") _wait_for_threads() assert _hits() == 1, f"Run 4b: retry on success must land 1 ping, got {_hits()}" diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 8af889d..3913872 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -2,10 +2,8 @@ from __future__ import annotations -import json import threading import time -from typing import Any from unittest.mock import MagicMock, patch import httpx @@ -15,7 +13,6 @@ _DEFAULT_CHECKPOINT_URL, _build_payload, _is_telemetry_enabled, - _normalize_arch, send_telemetry_ping, ) @@ -25,69 +22,65 @@ class TestIsTelemetryEnabled: - """Tests for the telemetry opt-in / opt-out logic.""" + """Tests for the v8.0 telemetry opt-in / opt-out logic. - def test_do_not_track_alone_does_NOT_disable(self) -> None: - """DO_NOT_TRACK=1 alone is no longer honored as an AxonFlow opt-out. + v8 contract: AXONFLOW_TELEMETRY=off is the SOLE opt-out lever. + Telemetry is otherwise ON for every mode (sandbox and production fire + on the same schedule; sandbox is tagged stream="sandbox" in payload). + The v7.x ``telemetry_enabled`` config override and ``has_credentials`` + parameter were removed — see CHANGELOG v8.0.0. + """ - Regression guard: host CLIs like Codex and Claude Code inject DNT=1 - unconditionally, so honoring it would prevent telemetry from any - plugin/SDK running inside those hosts regardless of user intent. - """ - with patch.dict("os.environ", {"DO_NOT_TRACK": "1"}, clear=True): - assert _is_telemetry_enabled("production", None, True) is True - assert _is_telemetry_enabled("production", True, True) is True + def test_enabled_by_default_no_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + """With no AXONFLOW_TELEMETRY env var, telemetry is ON.""" + monkeypatch.setenv("AXONFLOW_TELEMETRY", "") + assert _is_telemetry_enabled() is True - def test_disabled_by_env_axonflow(self) -> None: + def test_disabled_by_env_axonflow(self, monkeypatch: pytest.MonkeyPatch) -> None: """AXONFLOW_TELEMETRY=off disables telemetry.""" - with patch.dict("os.environ", {"AXONFLOW_TELEMETRY": "off"}): - assert _is_telemetry_enabled("production", None, True) is False - - def test_axonflow_off_still_disables_with_dnt_also_set(self) -> None: - """AXONFLOW_TELEMETRY=off is the canonical opt-out and wins regardless of DNT.""" - with patch.dict("os.environ", {"DO_NOT_TRACK": "1", "AXONFLOW_TELEMETRY": "off"}): - assert _is_telemetry_enabled("production", None, True) is False - assert _is_telemetry_enabled("production", True, True) is False + monkeypatch.setenv("AXONFLOW_TELEMETRY", "off") + assert _is_telemetry_enabled() is False - def test_disabled_by_env_axonflow_case_insensitive(self) -> None: + def test_disabled_by_env_axonflow_case_insensitive( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: """AXONFLOW_TELEMETRY=OFF (uppercase) also disables.""" - with patch.dict("os.environ", {"AXONFLOW_TELEMETRY": "OFF"}): - assert _is_telemetry_enabled("production", None, True) is False - - def test_disabled_sandbox_mode(self) -> None: - """Default OFF for sandbox mode when no explicit config.""" - with patch.dict("os.environ", {}, clear=True): - assert _is_telemetry_enabled("sandbox", None, True) is False + monkeypatch.setenv("AXONFLOW_TELEMETRY", "OFF") + assert _is_telemetry_enabled() is False - def test_enabled_production_with_credentials(self) -> None: - """Default ON for production mode with credentials.""" - with patch.dict("os.environ", {}, clear=True): - assert _is_telemetry_enabled("production", None, True) is True + def test_disabled_by_env_with_whitespace(self, monkeypatch: pytest.MonkeyPatch) -> None: + """AXONFLOW_TELEMETRY=' off ' (padded) also disables.""" + monkeypatch.setenv("AXONFLOW_TELEMETRY", " off ") + assert _is_telemetry_enabled() is False - def test_enabled_production_without_credentials(self) -> None: - """Default ON for production mode even without credentials.""" - with patch.dict("os.environ", {}, clear=True): - assert _is_telemetry_enabled("production", None, False) is True + def test_other_env_values_do_not_disable(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Any value other than 'off' (case-insensitive) leaves telemetry ON. - def test_config_override_true(self) -> None: - """Explicit True enables even in sandbox mode.""" - with patch.dict("os.environ", {}, clear=True): - assert _is_telemetry_enabled("sandbox", True, False) is True + Specifically, '0' / 'false' / 'no' DO NOT disable — only the literal + token 'off' is the opt-out. This matches the cross-SDK contract. + """ + for val in ("0", "false", "no", "true", "on", "anything"): + monkeypatch.setenv("AXONFLOW_TELEMETRY", val) + assert _is_telemetry_enabled() is True, f"value {val!r} should NOT disable" - def test_config_override_false(self) -> None: - """Explicit False disables even in production mode.""" - with patch.dict("os.environ", {}, clear=True): - assert _is_telemetry_enabled("production", False, True) is False + def test_do_not_track_alone_does_NOT_disable(self, monkeypatch: pytest.MonkeyPatch) -> None: + """DO_NOT_TRACK=1 alone is no longer honored as an AxonFlow opt-out. - def test_env_do_not_track_alone_does_NOT_beat_config_true(self) -> None: - """DNT=1 alone is no longer honored, so config=True still wins.""" - with patch.dict("os.environ", {"DO_NOT_TRACK": "1"}, clear=True): - assert _is_telemetry_enabled("production", True, True) is True + Regression guard: host CLIs like Codex and Claude Code inject DNT=1 + unconditionally, so honoring it would prevent telemetry from any + plugin/SDK running inside those hosts regardless of user intent. + """ + monkeypatch.setenv("DO_NOT_TRACK", "1") + monkeypatch.setenv("AXONFLOW_TELEMETRY", "") + assert _is_telemetry_enabled() is True - def test_env_axonflow_telemetry_beats_config_true(self) -> None: - """AXONFLOW_TELEMETRY=off beats config=True.""" - with patch.dict("os.environ", {"AXONFLOW_TELEMETRY": "off"}): - assert _is_telemetry_enabled("production", True, True) is False + def test_axonflow_off_still_disables_with_dnt_also_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """AXONFLOW_TELEMETRY=off is the canonical opt-out and wins regardless of DNT.""" + monkeypatch.setenv("DO_NOT_TRACK", "1") + monkeypatch.setenv("AXONFLOW_TELEMETRY", "off") + assert _is_telemetry_enabled() is False # --------------------------------------------------------------------------- @@ -125,6 +118,28 @@ def test_payload_instance_id_unique(self) -> None: p2 = _build_payload("production") assert p1["instance_id"] != p2["instance_id"] + def test_sandbox_mode_emits_stream_tag(self) -> None: + """v8 contract: sandbox-mode payload carries stream="sandbox" so + analytics can distinguish dev/test pings server-side without + conflating them with production heartbeat. + """ + payload = _build_payload("sandbox") + assert payload.get("stream") == "sandbox" + + def test_production_mode_omits_stream_tag(self) -> None: + """Production-mode payload omits the stream field entirely; the + server defaults absent stream to "heartbeat". Keeps wire shape + byte-identical with v7.x for the production path. + """ + payload = _build_payload("production") + assert "stream" not in payload + + def test_other_modes_omit_stream_tag(self) -> None: + """Any non-sandbox mode (empty / staging / unknown) omits stream.""" + for mode in ("", "staging", "unknown", "development"): + payload = _build_payload(mode) + assert "stream" not in payload, f"mode={mode!r} should not emit stream" + # --------------------------------------------------------------------------- # send_telemetry_ping integration tests @@ -132,25 +147,25 @@ def test_payload_instance_id_unique(self) -> None: class TestSendTelemetryPing: - """End-to-end tests for send_telemetry_ping.""" + """End-to-end tests for send_telemetry_ping under the v8 contract.""" @patch("axonflow.telemetry.httpx") - def test_payload_posted_correctly(self, mock_httpx: MagicMock) -> None: + def test_payload_posted_correctly( + self, mock_httpx: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: """Verify the HTTP POST is made with correct JSON payload.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"latest_version": None, "alerts": []} mock_httpx.post.return_value = mock_response - with patch.dict("os.environ", {}, clear=True): - send_telemetry_ping( - mode="production", - endpoint="https://agent.axonflow.com", - telemetry_enabled=None, - has_credentials=True, - ) - # Wait for daemon thread to complete. - _wait_for_threads() + monkeypatch.setenv("AXONFLOW_TELEMETRY", "") + send_telemetry_ping( + mode="production", + endpoint="https://agent.axonflow.com", + ) + # Wait for daemon thread to complete. + _wait_for_threads() mock_httpx.post.assert_called_once() call_args = mock_httpx.post.call_args @@ -161,92 +176,81 @@ def test_payload_posted_correctly(self, mock_httpx: MagicMock) -> None: assert payload["sdk"] == "python" assert payload["deployment_mode"] == "production" assert "instance_id" in payload + # Production-mode payload omits stream (server defaults to heartbeat). + assert "stream" not in payload @patch("axonflow.telemetry.httpx") - def test_disabled_skips_post(self, mock_httpx: MagicMock) -> None: + def test_disabled_skips_post( + self, mock_httpx: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: """When telemetry is disabled (AXONFLOW_TELEMETRY=off), no HTTP call is made.""" - with patch.dict("os.environ", {"AXONFLOW_TELEMETRY": "off"}): - send_telemetry_ping( - mode="production", - endpoint="https://agent.axonflow.com", - telemetry_enabled=None, - ) - _wait_for_threads() - mock_httpx.post.assert_not_called() - - @patch("axonflow.telemetry.httpx") - def test_sandbox_default_skips_post(self, mock_httpx: MagicMock) -> None: - """Sandbox mode with no config override skips telemetry.""" - with patch.dict("os.environ", {}, clear=True): - send_telemetry_ping( - mode="sandbox", - endpoint="https://agent.axonflow.com", - telemetry_enabled=None, - ) + monkeypatch.setenv("AXONFLOW_TELEMETRY", "off") + send_telemetry_ping( + mode="production", + endpoint="https://agent.axonflow.com", + ) _wait_for_threads() mock_httpx.post.assert_not_called() @patch("axonflow.telemetry.httpx") - def test_config_override_true_in_sandbox(self, mock_httpx: MagicMock) -> None: - """Config override=True enables ping even in sandbox mode.""" + def test_sandbox_mode_fires_with_stream_tag( + self, mock_httpx: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + """v8 contract: sandbox-mode pings FIRE (no longer suppressed) and + carry stream="sandbox" in the payload. Pre-v8 this test would have + asserted no ping was sent — see CHANGELOG v8.0.0 for the rationale. + """ mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {} mock_httpx.post.return_value = mock_response - with patch.dict("os.environ", {}, clear=True): - send_telemetry_ping( - mode="sandbox", - endpoint="https://agent.axonflow.com", - telemetry_enabled=True, - ) + monkeypatch.setenv("AXONFLOW_TELEMETRY", "") + send_telemetry_ping( + mode="sandbox", + endpoint="https://agent.axonflow.com", + ) _wait_for_threads() - mock_httpx.post.assert_called_once() - @patch("axonflow.telemetry.httpx") - def test_config_override_false_in_production(self, mock_httpx: MagicMock) -> None: - """Config override=False disables ping even in production mode.""" - with patch.dict("os.environ", {}, clear=True): - send_telemetry_ping( - mode="production", - endpoint="https://agent.axonflow.com", - telemetry_enabled=False, - ) - _wait_for_threads() - mock_httpx.post.assert_not_called() + mock_httpx.post.assert_called_once() + payload = mock_httpx.post.call_args[1].get("json") or mock_httpx.post.call_args[0][1] + assert payload["deployment_mode"] == "sandbox" + assert payload.get("stream") == "sandbox" @patch("axonflow.telemetry.httpx") - def test_silent_failure_on_connection_error(self, mock_httpx: MagicMock) -> None: + def test_silent_failure_on_connection_error( + self, mock_httpx: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: """Connection errors are swallowed silently.""" mock_httpx.post.side_effect = httpx.ConnectError("connection refused") - with patch.dict("os.environ", {}, clear=True): - # Should not raise. - send_telemetry_ping( - mode="production", - endpoint="https://agent.axonflow.com", - telemetry_enabled=None, - has_credentials=True, - ) + monkeypatch.setenv("AXONFLOW_TELEMETRY", "") + # Should not raise. + send_telemetry_ping( + mode="production", + endpoint="https://agent.axonflow.com", + ) _wait_for_threads() # No exception = pass. @patch("axonflow.telemetry.httpx") - def test_silent_failure_on_timeout(self, mock_httpx: MagicMock) -> None: + def test_silent_failure_on_timeout( + self, mock_httpx: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: """Timeout errors are swallowed silently.""" mock_httpx.post.side_effect = httpx.TimeoutException("timed out") - with patch.dict("os.environ", {}, clear=True): - send_telemetry_ping( - mode="production", - endpoint="https://agent.axonflow.com", - telemetry_enabled=None, - has_credentials=True, - ) + monkeypatch.setenv("AXONFLOW_TELEMETRY", "") + send_telemetry_ping( + mode="production", + endpoint="https://agent.axonflow.com", + ) _wait_for_threads() @patch("axonflow.telemetry.httpx") - def test_custom_endpoint_via_env(self, mock_httpx: MagicMock) -> None: + def test_custom_endpoint_via_env( + self, mock_httpx: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: """AXONFLOW_CHECKPOINT_URL overrides the default endpoint.""" custom_url = "https://custom-checkpoint.example.com/v1/ping" mock_response = MagicMock() @@ -254,16 +258,12 @@ def test_custom_endpoint_via_env(self, mock_httpx: MagicMock) -> None: mock_response.json.return_value = {} mock_httpx.post.return_value = mock_response - with patch.dict( - "os.environ", - {"AXONFLOW_CHECKPOINT_URL": custom_url}, - clear=True, - ): - send_telemetry_ping( - mode="production", - endpoint="https://agent.axonflow.com", - telemetry_enabled=True, - ) + monkeypatch.setenv("AXONFLOW_TELEMETRY", "") + monkeypatch.setenv("AXONFLOW_CHECKPOINT_URL", custom_url) + send_telemetry_ping( + mode="production", + endpoint="https://agent.axonflow.com", + ) _wait_for_threads() call_args = mock_httpx.post.call_args @@ -271,22 +271,20 @@ def test_custom_endpoint_via_env(self, mock_httpx: MagicMock) -> None: assert url == custom_url @patch("axonflow.telemetry.httpx") - def test_outdated_version_warning(self, mock_httpx: MagicMock) -> None: + def test_outdated_version_warning( + self, mock_httpx: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: """When server reports a newer version, a warning is logged.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"latest_version": "99.0.0", "alerts": []} mock_httpx.post.return_value = mock_response - with ( - patch.dict("os.environ", {}, clear=True), - patch("axonflow.telemetry.logger") as mock_logger, - ): + monkeypatch.setenv("AXONFLOW_TELEMETRY", "") + with patch("axonflow.telemetry.logger") as mock_logger: send_telemetry_ping( mode="production", endpoint="https://agent.axonflow.com", - telemetry_enabled=None, - has_credentials=True, ) _wait_for_threads() @@ -295,7 +293,9 @@ def test_outdated_version_warning(self, mock_httpx: MagicMock) -> None: assert "newer" in warning_msg.lower() or "available" in warning_msg.lower() @patch("axonflow.telemetry.httpx") - def test_timeout_passed_to_post(self, mock_httpx: MagicMock) -> None: + def test_timeout_passed_to_post( + self, mock_httpx: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: """POST timeout is derived from the shared telemetry deadline, so it is bounded by the total budget (``_TIMEOUT_SECONDS``) and comfortably positive after the health probe. Under mocks the health probe returns @@ -306,13 +306,11 @@ def test_timeout_passed_to_post(self, mock_httpx: MagicMock) -> None: mock_response.json.return_value = {} mock_httpx.post.return_value = mock_response - with patch.dict("os.environ", {}, clear=True): - send_telemetry_ping( - mode="production", - endpoint="https://agent.axonflow.com", - telemetry_enabled=None, - has_credentials=True, - ) + monkeypatch.setenv("AXONFLOW_TELEMETRY", "") + send_telemetry_ping( + mode="production", + endpoint="https://agent.axonflow.com", + ) _wait_for_threads() call_kwargs = mock_httpx.post.call_args[1] @@ -322,19 +320,19 @@ def test_timeout_passed_to_post(self, mock_httpx: MagicMock) -> None: assert 2.0 < call_kwargs["timeout"] <= 3.0 @patch("axonflow.telemetry.httpx") - def test_non_200_response_no_crash(self, mock_httpx: MagicMock) -> None: + def test_non_200_response_no_crash( + self, mock_httpx: MagicMock, monkeypatch: pytest.MonkeyPatch + ) -> None: """Non-200 responses are handled gracefully.""" mock_response = MagicMock() mock_response.status_code = 500 mock_httpx.post.return_value = mock_response - with patch.dict("os.environ", {}, clear=True): - send_telemetry_ping( - mode="production", - endpoint="https://agent.axonflow.com", - telemetry_enabled=None, - has_credentials=True, - ) + monkeypatch.setenv("AXONFLOW_TELEMETRY", "") + send_telemetry_ping( + mode="production", + endpoint="https://agent.axonflow.com", + ) _wait_for_threads() # No exception = pass. From e58c966a208cab46cd5d40940527b18d3145d31b Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 8 May 2026 11:56:57 +0200 Subject: [PATCH 2/2] fix(lint): refresh falsey-clobber baseline after v8 line shifts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v8 telemetry refactor removed several lines from client.py (telemetry_enabled propagation), shifting subsequent line numbers down by 2. The pre-existing falsey-clobber lint baseline is keyed on exact line numbers, so CI saw both '15 NEW findings' and '15 stale baseline entries' for the same code that was unchanged in semantics — pure positional drift. Re-running with --write-baseline rewrites the file to current line numbers; no actual lint violations were introduced. Signed-off-by: Saurabh Jain --- .lint_baselines/falsey_clobber.json | 30 ++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.lint_baselines/falsey_clobber.json b/.lint_baselines/falsey_clobber.json index 552c298..4eb4c97 100644 --- a/.lint_baselines/falsey_clobber.json +++ b/.lint_baselines/falsey_clobber.json @@ -22,24 +22,24 @@ "axonflow/adapters/tool_wrapper.py:190:20", "axonflow/adapters/tool_wrapper.py:208:20", "axonflow/adapters/tool_wrapper.py:220:20", - "axonflow/client.py:1100:16", - "axonflow/client.py:1177:16", - "axonflow/client.py:1649:37", - "axonflow/client.py:1690:18", - "axonflow/client.py:1748:37", - "axonflow/client.py:2266:24", - "axonflow/client.py:2287:33", - "axonflow/client.py:2288:31", - "axonflow/client.py:2300:25", - "axonflow/client.py:2361:28", - "axonflow/client.py:2402:69", + "axonflow/client.py:1098:16", + "axonflow/client.py:1175:16", + "axonflow/client.py:1647:37", + "axonflow/client.py:1688:18", + "axonflow/client.py:1746:37", + "axonflow/client.py:2264:24", + "axonflow/client.py:2285:33", + "axonflow/client.py:2286:31", + "axonflow/client.py:2298:25", + "axonflow/client.py:2359:28", + "axonflow/client.py:2400:69", "axonflow/client.py:292:14", "axonflow/client.py:297:24", "axonflow/client.py:298:20", - "axonflow/client.py:523:44", - "axonflow/client.py:6206:25", - "axonflow/client.py:834:20", - "axonflow/client.py:920:20", + "axonflow/client.py:521:44", + "axonflow/client.py:6204:25", + "axonflow/client.py:832:20", + "axonflow/client.py:918:20", "axonflow/execution.py:205:19", "axonflow/interceptors/anthropic.py:134:43", "axonflow/interceptors/anthropic.py:161:43",