From e87aa2002840a649f581262021d07dd85221adb2 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:05:31 +0530 Subject: [PATCH 1/2] Anonymous usage stats, opt-out with CASE_TELEMETRY=0 cased sends three events to PostHog: install_ping at boot, install_heartbeat once per UTC day from the sweeper, and run_completed from the scheduler. Each carries a random install id (persisted in $CASE_HOME/telemetry.json), counts of computers, credentials, schedules and recent runs, and for run_completed the status, kind, duration and whether a screenshot was captured. Never a name, prompt, domain, credential, hostname or path. Off with CASE_TELEMETRY=0 or DO_NOT_TRACK=1, and off automatically inside test runs. Sends are daemon threads with a 5 s timeout and never raise. --- README.md | 5 ++ control-plane/cased.py | 5 ++ control-plane/scheduler.py | 9 ++- control-plane/store.py | 14 +++++ control-plane/telemetry.py | 114 +++++++++++++++++++++++++++++++++++++ tests/test_telemetry.py | 113 ++++++++++++++++++++++++++++++++++++ 6 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 control-plane/telemetry.py create mode 100644 tests/test_telemetry.py diff --git a/README.md b/README.md index ead692a..d093fa3 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,11 @@ The defaults are enough to try Case locally. Optional settings are listed in `compose.yaml`, then run `docker compose up -d` to apply changes. Edit an existing `.env` rather than replacing it. +Case sends a few anonymous numbers that help us see what breaks: a random install +id, how many computers and schedules exist, and whether scheduled runs succeed. +Never a name, prompt, domain or credential. Turn it off with `CASE_TELEMETRY=0` +(or `DO_NOT_TRACK=1`) in `.env`. +
Stop and start again diff --git a/control-plane/cased.py b/control-plane/cased.py index 7f4e49b..87235ea 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -37,6 +37,7 @@ import login_flow import scheduler import session_keeper +import telemetry from config import (API_BASE, AUDIT_DIR, BIND_HOST, BIND_PORT, IMAGE, MAX_COMPUTER_RAM_MB, MAX_CPUS, MAX_RAM_MB, MAX_RUNNING, MIN_CPUS, MIN_RAM_MB, RUNS_DIR, VNC_PORT, log) @@ -65,6 +66,9 @@ async def lifespan(_app): notifier.listen(handoffs.on_ntfy_answer) log.info("cased up on %s (image=%s, max_running=%d, max_ram_mb=%d, captcha_auto=%s)", API_BASE, IMAGE, MAX_RUNNING, MAX_RAM_MB, "on" if captcha.enabled() else "off") + log.info("usage stats %s", "on (anonymous; CASE_TELEMETRY=0 to turn off)" + if telemetry.ENABLED else "off") + telemetry.install_ping() yield # SIGTERM (docker compose down, systemctl stop): park the desktops. They are not # compose services, so nothing else would. @@ -1101,6 +1105,7 @@ def sweeper(): prune_old_audit_files() store.prune_terminal_handoffs(cutoff) scheduler.fire_due_schedules(_spawn) + telemetry.heartbeat_if_due() # at most one event per UTC day # preflight persistent session health: it drives desks over the network, # and a hung one must not stall reconcile or the schedule fire loop threading.Thread(target=session_keeper.tick, daemon=True).start() diff --git a/control-plane/scheduler.py b/control-plane/scheduler.py index 7abbabe..506e7f9 100644 --- a/control-plane/scheduler.py +++ b/control-plane/scheduler.py @@ -15,6 +15,7 @@ import shutil import subprocess import threading +import time from datetime import datetime, timedelta, timezone from zoneinfo import ZoneInfo, ZoneInfoNotFoundError @@ -28,6 +29,7 @@ from lifecycle import do_sleep, do_wake, get_computer from notify import notifier from store import store +import telemetry from util import new_id, now SCHED_RUNNING = set() # in-memory guard, fine while one cased process runs @@ -203,7 +205,7 @@ def run_schedule(sid): tz = s["tz"] if "tz" in s.keys() else None # Reschedule FIRST so a hung/crashed run never wedges the slot. store.set_schedule_next(sid, compute_next(s["kind"], s["spec"], s["jitter_s"], tz)) - cid, rid, started = s["computer_id"], new_id("run"), now() + cid, rid, started, t0 = s["computer_id"], new_id("run"), now(), time.monotonic() code, summary, status, artifact = -1, "", "fail", None # Only the run that woke an asleep box may put it back, never borrow a live session # (and never sleep under an active AuthAttempt; do_sleep also 409s as a belt). @@ -241,6 +243,11 @@ def run_schedule(sid): shot = " 📸" if artifact else "" notifier.push(f"[{s['name']}] {status}: {summary[:200]}{shot}") emit("schedule.run", {"schedule": sid, "run": rid, "computer_id": cid, "status": status}) + # status and shape only; the prompt and its output never leave the box + telemetry.capture("run_completed", + {"status": status, "kind": s["kind"], + "duration_s": int(time.monotonic() - t0), + "had_artifact": bool(artifact)}) finally: SCHED_RUNNING.discard(sid) diff --git a/control-plane/store.py b/control-plane/store.py index c8e4525..b6cab73 100644 --- a/control-plane/store.py +++ b/control-plane/store.py @@ -613,6 +613,20 @@ def list_all_runs(self, limit=50): def get_run(self, rid): return self.one("SELECT * FROM runs WHERE id=?", (rid,)) + def telemetry_counts(self, since): + """Counts only: this leaves the box as anonymous usage stats. Nothing here + may become a name, prompt, domain or username.""" + n = lambda sql, args=(): self.one(sql, args)["c"] + return { + "computers": n("SELECT COUNT(*) c FROM computers"), + "credentials": n("SELECT COUNT(*) c FROM credentials"), + "schedules": n("SELECT COUNT(*) c FROM schedules"), + "schedules_enabled": n("SELECT COUNT(*) c FROM schedules WHERE enabled=1"), + "runs_7d": n("SELECT COUNT(*) c FROM runs WHERE started_at >= ?", (since,)), + "runs_ok_7d": n("SELECT COUNT(*) c FROM runs WHERE started_at >= ? " + "AND status='ok'", (since,)), + } + def prune_terminal_handoffs(self, cutoff): self.q("UPDATE handoffs SET screenshot=NULL WHERE screenshot IS NOT NULL " "AND status IN ('completed','answered','failed','expired')") diff --git a/control-plane/telemetry.py b/control-plane/telemetry.py new file mode 100644 index 0000000..07195ef --- /dev/null +++ b/control-plane/telemetry.py @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: AGPL-3.0-only +"""Anonymous usage stats that help improve Case. + +Three events: install_ping (boot), install_heartbeat (once per UTC day), and +run_completed (did a scheduled run work). Each carries a random install id and a +handful of counts. Never a name, prompt, domain, URL, username, or anything from +the vault. + +Opt out with CASE_TELEMETRY=0 or DO_NOT_TRACK=1: nothing is sent and no id is +minted. The id lives in plain JSON at $CASE_HOME/telemetry.json; delete the file +to reset it. + +Every send is a daemon thread with a short timeout, so a slow or missing network +never delays a request or a boot. Failures are dropped at debug level. +""" +import json +import os +import sys +import threading +import uuid +from datetime import datetime, timedelta, timezone + +import requests + +from config import CASE_HOME, log +from store import store +from util import now + +URL = "https://bios.case.computer/i/v0/e/" # our PostHog ingest proxy +KEY = "phc_kQYe9hUva7ABjyDmvXxfiAuvJHqYNfB8QonRw9sBY4JH" # public write-only token +PATH = os.path.join(CASE_HOME, "telemetry.json") + +_CHOICE = (os.environ.get("CASE_TELEMETRY") or "").strip().lower() +# The unit tests would otherwise ping on every run. CASE_TELEMETRY=1 is the +# override tests/test_telemetry.py uses. +_TEST_RUN = ("pytest" in sys.modules + or os.path.basename(sys.argv[0] or "").startswith("test_")) +ENABLED = ((os.environ.get("DO_NOT_TRACK") or "").strip().lower() not in ("1", "true", "yes", "on") + and _CHOICE not in ("0", "false", "no", "off") + and (not _TEST_RUN or _CHOICE in ("1", "true", "yes", "on"))) +_LOCK = threading.Lock() + + +def _load(): + try: + with open(PATH) as f: + s = json.load(f) + if isinstance(s, dict) and s.get("install_id"): + return s + except Exception: + pass + s = {"install_id": uuid.uuid4().hex, "first_seen": now()} + _save(s) + return s + + +def _save(s): + os.makedirs(CASE_HOME, exist_ok=True) + tmp = PATH + ".tmp" + with open(tmp, "w") as f: + json.dump(s, f, indent=2) + os.replace(tmp, PATH) + + +def _age_days(s): + try: + seen = datetime.strptime(s["first_seen"], "%Y-%m-%dT%H:%M:%SZ") + return (datetime.now(timezone.utc).replace(tzinfo=None) - seen).days + except Exception: + return 0 + + +def counts(): + since = (datetime.now(timezone.utc) - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%SZ") + try: + return store.telemetry_counts(since) + except Exception: + return {} + + +def capture(event, props=None): + """Fire-and-forget: returns immediately, never raises.""" + if not ENABLED: + return + threading.Thread(target=_send, args=(event, props or {}), daemon=True).start() + + +def _send(event, props): + try: + s = _load() + properties = {**props, "self_host": True, "install_age_days": _age_days(s)} + requests.post(URL, timeout=5, json={ + "api_key": KEY, "event": event, "distinct_id": s["install_id"], + "timestamp": now(), "properties": properties}) + except Exception: + log.debug("usage stats %s dropped", event, exc_info=True) + + +def install_ping(): + capture("install_ping", counts()) + + +def heartbeat_if_due(): + """Sweeper: at most one event per UTC day per install.""" + if not ENABLED: + return + today = now()[:10] + with _LOCK: + s = _load() + if s.get("last_heartbeat_day") == today: + return + s["last_heartbeat_day"] = today + _save(s) + capture("install_heartbeat", counts()) diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py new file mode 100644 index 0000000..7d6a3c4 --- /dev/null +++ b/tests/test_telemetry.py @@ -0,0 +1,113 @@ +# SPDX-License-Identifier: MIT +"""Usage stats: opt-out is real, the payload is anonymous, the heartbeat is daily. +Run: .venv/bin/python tests/test_telemetry.py""" +import importlib +import json +import os +import shutil +import sys +import unittest.mock as mock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) +HOME = "/tmp/case-telemetry-test" +shutil.rmtree(HOME, ignore_errors=True) +os.environ["CASE_HOME"] = HOME +os.environ.pop("CASE_TELEMETRY", None) +os.environ.pop("DO_NOT_TRACK", None) + +import telemetry # noqa: E402 +from store import store # noqa: E402 + +# Everything a payload is allowed to carry. A new key here is a deliberate act: +# usage stats leave the box, so anything not on this list is a leak. +ALLOWED = { + "self_host", "install_age_days", + "computers", "credentials", "schedules", "schedules_enabled", + "runs_7d", "runs_ok_7d", # install_ping / heartbeat + "status", "kind", "duration_s", "had_artifact", # run_completed +} +# The only string-valued properties, and the only values they may take. +ENUMS = {"status": {"ok", "fail", "skipped"}, "kind": {"interval", "daily"}} + + +def _on(): + """Stats are off inside a test run by design; these tests opt back in.""" + os.environ["CASE_TELEMETRY"] = "1" + t = importlib.reload(telemetry) + assert t.ENABLED + return t + + +def test_off_by_default_inside_a_test_run(): + os.environ.pop("CASE_TELEMETRY", None) + t = importlib.reload(telemetry) + assert not t.ENABLED, "the suite would ingest into the live PostHog project" + + +def test_opt_out_sends_nothing(): + for var in ("CASE_TELEMETRY", "DO_NOT_TRACK"): + os.environ["CASE_TELEMETRY"] = "1" # the override the suite uses + os.environ[var] = "0" if var == "CASE_TELEMETRY" else "1" + t = importlib.reload(telemetry) + try: + assert not t.ENABLED, f"{var} did not disable usage stats" + with mock.patch.object(t.requests, "post") as post: + t.capture("run_completed", {"status": "ok"}) + t.install_ping() + t.heartbeat_if_due() + assert not post.called, f"{var} set but a request went out" + finally: + os.environ.pop(var, None) + os.environ.pop("CASE_TELEMETRY", None) + + +def test_payload_is_anonymous_and_the_id_is_stable(): + t = _on() + with mock.patch.object(t.requests, "post") as post: + t._send("run_completed", {"status": "ok", "duration_s": 12, + "had_artifact": True, "kind": "daily"}) + t._send("install_ping", t.counts()) + bodies = [c.kwargs["json"] for c in post.call_args_list] + assert len(bodies) == 2 + ids = {b["distinct_id"] for b in bodies} + assert len(ids) == 1 and len(ids.pop()) == 32, "install id must be one stable hex id" + for b in bodies: + assert set(b["properties"]) <= ALLOWED, \ + f"unlisted property leaving the box: {set(b['properties']) - ALLOWED}" + for k, v in b["properties"].items(): + if isinstance(v, str): + assert v in ENUMS.get(k, ()), f"free-form string in payload: {k}={v!r}" + else: + assert isinstance(v, (bool, int)), f"{k}={v!r} is not a number" + assert b["api_key"] == t.KEY and b["event"] + # the id lives in a plain file the operator can read or delete + with open(t.PATH) as f: + assert len(json.load(f)["install_id"]) == 32 + + +def test_heartbeat_is_once_a_day(): + t = _on() + with mock.patch.object(t, "capture") as cap: + t.heartbeat_if_due() + t.heartbeat_if_due() + assert cap.call_count == 1, "heartbeat fired twice in one day" + s = t._load() + s["last_heartbeat_day"] = "2000-01-01" + t._save(s) + t.heartbeat_if_due() + assert cap.call_count == 2, "heartbeat did not fire on a new day" + + +def test_counts_are_counts(): + c = store.telemetry_counts("2000-01-01T00:00:00Z") + assert set(c) == {"computers", "credentials", "schedules", "schedules_enabled", + "runs_7d", "runs_ok_7d"} + assert all(type(v) is int for v in c.values()), c + + +if __name__ == "__main__": + for name, fn in sorted(globals().items()): + if name.startswith("test_"): + fn() + print("ok", name) + print("PASS") From d54772b049431ed18f2cbebd2a107752310400d8 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Sat, 12 Sep 2026 07:25:23 +0530 Subject: [PATCH 2/2] Say the proxy sees the IP, and lock install id creation Review follow-up: the README no longer calls the stats anonymous, since the ingest proxy sees the request IP like any web server. _load now holds the lock so two sends racing on a missing telemetry.json mint one id. --- README.md | 8 ++++---- control-plane/cased.py | 2 +- control-plane/telemetry.py | 26 ++++++++++++++------------ 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d093fa3..ede43ec 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,10 @@ The defaults are enough to try Case locally. Optional settings are listed in `compose.yaml`, then run `docker compose up -d` to apply changes. Edit an existing `.env` rather than replacing it. -Case sends a few anonymous numbers that help us see what breaks: a random install -id, how many computers and schedules exist, and whether scheduled runs succeed. -Never a name, prompt, domain or credential. Turn it off with `CASE_TELEMETRY=0` -(or `DO_NOT_TRACK=1`) in `.env`. +Case sends a few usage numbers that help us see what breaks: a random install id, +how many computers and schedules exist, and whether scheduled runs succeed. Never +a name, prompt, domain or credential. Like any web request it reaches our server +with your IP. Turn it off with `CASE_TELEMETRY=0` (or `DO_NOT_TRACK=1`) in `.env`.
diff --git a/control-plane/cased.py b/control-plane/cased.py index 87235ea..f21c60e 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -66,7 +66,7 @@ async def lifespan(_app): notifier.listen(handoffs.on_ntfy_answer) log.info("cased up on %s (image=%s, max_running=%d, max_ram_mb=%d, captcha_auto=%s)", API_BASE, IMAGE, MAX_RUNNING, MAX_RAM_MB, "on" if captcha.enabled() else "off") - log.info("usage stats %s", "on (anonymous; CASE_TELEMETRY=0 to turn off)" + log.info("usage stats %s", "on (CASE_TELEMETRY=0 to turn off)" if telemetry.ENABLED else "off") telemetry.install_ping() yield diff --git a/control-plane/telemetry.py b/control-plane/telemetry.py index 07195ef..6e3229c 100644 --- a/control-plane/telemetry.py +++ b/control-plane/telemetry.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: AGPL-3.0-only -"""Anonymous usage stats that help improve Case. +"""Usage stats that help improve Case. Three events: install_ping (boot), install_heartbeat (once per UTC day), and run_completed (did a scheduled run work). Each carries a random install id and a @@ -38,20 +38,22 @@ ENABLED = ((os.environ.get("DO_NOT_TRACK") or "").strip().lower() not in ("1", "true", "yes", "on") and _CHOICE not in ("0", "false", "no", "off") and (not _TEST_RUN or _CHOICE in ("1", "true", "yes", "on"))) -_LOCK = threading.Lock() +_LOCK = threading.RLock() def _load(): - try: - with open(PATH) as f: - s = json.load(f) - if isinstance(s, dict) and s.get("install_id"): - return s - except Exception: - pass - s = {"install_id": uuid.uuid4().hex, "first_seen": now()} - _save(s) - return s + # Locked so two sends racing on a missing file mint one id, not two. + with _LOCK: + try: + with open(PATH) as f: + s = json.load(f) + if isinstance(s, dict) and s.get("install_id"): + return s + except Exception: + pass + s = {"install_id": uuid.uuid4().hex, "first_seen": now()} + _save(s) + return s def _save(s):