From cdc473436a3e2fa92cf5003ba05aba1a59ec7e91 Mon Sep 17 00:00:00 2001 From: RomirJ Date: Wed, 10 Jun 2026 16:40:47 -0700 Subject: [PATCH] fix(telemetry): honor the onboarding opt-out in the live emit() path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit ยง3.11 / Part 1 #19 (Python side). The only live telemetry emitter (pro/license.load_license โ†’ telemetry.emit) checked only TETHER_NO_TELEMETRY. The onboarding "Enable anonymous telemetry?" answer and `tether config set telemetry off` write an onboarding telemetry_enabled flag that was honored ONLY by emit_free โ€” which is dead code (zero call sites). So a user who explicitly answered "no" kept emitting. - emit() now also checks _is_telemetry_enabled_by_onboarding(); opting out via the first-run prompt / config actually stops the heartbeat. - _is_telemetry_enabled_by_onboarding() now honors TETHER_HOME (it hardcoded ~/.tether, disagreeing with the free-tier cache which already respects it). - README "no telemetry by default" was misleading: corrected to state the free CLI sends none and Pro sends an opt-out daily heartbeat (disable via TETHER_NO_TELEMETRY=1 or `tether config set telemetry off`). Tests: new emit-honors-onboarding-opt-out + opt-in cases; added an autouse fixture isolating TETHER_HOME so the emit tests are hermetic regardless of the developer's real ~/.tether/onboarding.json (latent flakiness this gate would otherwise expose). 16 pass; ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- src/tether/pro/telemetry.py | 16 +++++++++++-- tests/test_pro_telemetry.py | 45 +++++++++++++++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6ca4109..956445c 100644 --- a/README.md +++ b/README.md @@ -507,6 +507,6 @@ For commercial licensing inquiries (offering Tether as a hosted service to compe --- -Tether is built by [FastCrest](https://fastcrest.com). No signup, no telemetry by default. +Tether is built by [FastCrest](https://fastcrest.com). No signup required. The free CLI sends no usage telemetry. Tether **Pro** sends an anonymized usage heartbeat once per day (opt-out, on by default) โ€” disable any time with `TETHER_NO_TELEMETRY=1` or `tether config set telemetry off`. Made with ๐Ÿ”ฅ Passion in San Francisco diff --git a/src/tether/pro/telemetry.py b/src/tether/pro/telemetry.py index c8a984a..66a88ea 100644 --- a/src/tether/pro/telemetry.py +++ b/src/tether/pro/telemetry.py @@ -156,6 +156,13 @@ def emit( if _is_disabled(): logger.debug("Telemetry disabled via TETHER_NO_TELEMETRY env; skipping.") return False + if not _is_telemetry_enabled_by_onboarding(): + # The first-run prompt and `tether config set telemetry off` write the + # onboarding telemetry_enabled flag. This live heartbeat path used to + # ignore it entirely (only the dead emit_free path honored it), so a + # user who answered "no" kept emitting. Honor it here. + logger.debug("Telemetry disabled via onboarding/config opt-out; skipping.") + return False if not customer_id: logger.debug("Telemetry skipped: no customer_id (free tier).") return False @@ -297,9 +304,14 @@ def _write_free_cache() -> None: def _is_telemetry_enabled_by_onboarding() -> bool: - """Check onboarding.json for telemetry opt-out. Returns True if enabled or missing.""" + """Check onboarding.json for the telemetry opt-out. True if enabled/missing. + + Honors TETHER_HOME so a custom config home reads the right onboarding.json + (the free-tier cache already respects it โ€” these must agree). + """ try: - onboarding_path = Path("~/.tether/onboarding.json").expanduser() + home = Path(os.environ.get("TETHER_HOME", Path.home() / ".tether")) + onboarding_path = home / "onboarding.json" if not onboarding_path.exists(): return True # default: telemetry on data = json.loads(onboarding_path.read_text()) diff --git a/tests/test_pro_telemetry.py b/tests/test_pro_telemetry.py index 76c7aa5..f8c0a36 100644 --- a/tests/test_pro_telemetry.py +++ b/tests/test_pro_telemetry.py @@ -5,7 +5,6 @@ """ from __future__ import annotations -import os from unittest.mock import MagicMock, patch import pytest @@ -13,12 +12,21 @@ from tether.pro.telemetry import ( DEFAULT_TELEMETRY_ENDPOINT, HEARTBEAT_SCHEMA_VERSION, - HeartbeatPayload, build_payload, emit, ) +@pytest.fixture(autouse=True) +def _isolated_tether_home(tmp_path, monkeypatch): + """Point TETHER_HOME at a clean dir so emit()'s onboarding opt-out check + reads a known-empty state (telemetry enabled by default) regardless of the + developer's real ~/.tether/onboarding.json. Tests opt out explicitly.""" + monkeypatch.setenv("TETHER_HOME", str(tmp_path)) + monkeypatch.delenv("TETHER_NO_TELEMETRY", raising=False) + return tmp_path + + def test_build_payload_returns_locked_v1_shape() -> None: """Phase 1 payload shape is LOCKED โ€” additive-only Phase 2.""" p = build_payload( @@ -65,6 +73,39 @@ def test_emit_skips_for_empty_customer_id() -> None: assert result is False +def test_emit_honors_onboarding_opt_out(_isolated_tether_home) -> None: + """The live emit() path must honor the onboarding/config opt-out flag. + + Regression: this path used to check only TETHER_NO_TELEMETRY, so a user who + answered "no" at first-run (or `tether config set telemetry off`) kept + emitting. Only the dead emit_free path honored the flag. + """ + import json + + (_isolated_tether_home / "onboarding.json").write_text( + json.dumps({"telemetry_enabled": False}) + ) + with patch("httpx.post") as mock_post: + result = emit(customer_id="cust_alice", tether_version="0.12.0") + mock_post.assert_not_called() + assert result is False + + +def test_emit_proceeds_when_onboarding_opts_in(_isolated_tether_home) -> None: + """telemetry_enabled=True (or absent) lets the heartbeat through.""" + import json + + (_isolated_tether_home / "onboarding.json").write_text( + json.dumps({"telemetry_enabled": True}) + ) + fake_response = MagicMock() + fake_response.status_code = 204 + with patch("httpx.post", return_value=fake_response) as mock_post: + result = emit(customer_id="cust_alice", tether_version="0.12.0") + assert result is True + mock_post.assert_called_once() + + def test_emit_swallows_network_failure(monkeypatch: pytest.MonkeyPatch) -> None: """Telemetry failure must NEVER raise to the caller.""" monkeypatch.delenv("TETHER_NO_TELEMETRY", raising=False)