Skip to content
Merged
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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`.

<a id="stop-and-start-again"></a>
<details>
<summary>Stop and start again</summary>
Expand Down
5 changes: 5 additions & 0 deletions control-plane/cased.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 (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.
Expand Down Expand Up @@ -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()
Expand Down
9 changes: 8 additions & 1 deletion control-plane/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import shutil
import subprocess
import threading
import time
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions control-plane/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')")
Expand Down
116 changes: 116 additions & 0 deletions control-plane/telemetry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# SPDX-License-Identifier: AGPL-3.0-only
"""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.RLock()


def _load():
# 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):
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)
Comment on lines +61 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Telemetry state can race

_save uses one shared .tmp path without consistently locking _load and _save. If the documented telemetry file is deleted while the service is running, concurrent completion events can both initialize it, race on that temporary file, and send different install IDs or drop an event when one os.replace loses the race. Serialize state initialization and updates, or use unique temporary files with an atomic locked update, to preserve the promised stable install identity.

Fix in Cursor Fix in Claude Code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: _load takes the lock (RLock) so concurrent sends on a missing file mint a single id.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: _load takes the lock (RLock) so concurrent sends on a missing file mint a single id.



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())
113 changes: 113 additions & 0 deletions tests/test_telemetry.py
Original file line number Diff line number Diff line change
@@ -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")
Loading