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):