Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
16 changes: 14 additions & 2 deletions src/tether/pro/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down
45 changes: 43 additions & 2 deletions tests/test_pro_telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,28 @@
"""
from __future__ import annotations

import os
from unittest.mock import MagicMock, patch

import pytest

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(
Expand Down Expand Up @@ -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)
Expand Down
Loading