inferrail

0019. An opt-in, anonymous usage ping

Status

Accepted, default-off posture superseded 2026-09-18 by 0020 — an explicit founder decision to switch to opt-out (on by default), recorded there plainly as a reversal, not silently changed here. This document is left otherwise unmodified as the historical record of the original decision and its reasoning; everything below except “default off” itself (the payload shape discipline, the no-baked-in-endpoint safeguard, fail-silent networking, telemetry preview) still describes current behavior — see ADR-0020 for the exact, itemized diff.

Context

Every “download it” claim in MISSION.md has, until now, had no after-install signal at all — PyPI download counts and GitHub clones/stars are free and already available, but they say nothing about whether an install ever reaches first value (a tool connected, a receipt, a budget). The founder explicitly wants this signal captured before the HN launch, with hard, non-negotiable privacy constraints: opt-in, default off, anonymous, lifecycle-only (never per-request), fails silently, independently verifiable by anyone who doesn’t want to trust this document.

This sits directly against MISSION.md’s own non-negotiable — “no telemetry without explicit opt-in (default off)” — and against telemetry/sinks.py’s and receipts/sinks.py’s own docstring guarantees that neither of those existing subsystems transmits data off the machine. This feature is the one deliberate, narrow exception to that whole-repo default, and it has to be architecturally impossible to confuse with either of those existing local-only systems.

Decision

A new inferrail.usage_ping package, entirely separate from telemetry/ and receipts/. It does not reuse TelemetrySink or ReceiptSink as its transport — only a receipt_hook.UsagePingReceiptSink wraps a ReceiptSink to observe when a receipt happens, without ever reading a receipt’s business-attribute fields.

The exact payload (usage_ping/payload.py) is fixed and exhaustive: a locally-generated random install_id (not derived from any machine/hardware identifier), os, inferrail_version, event, ts. Four lifecycle events only: first_run, tool_connected, first_receipt, budget_created. Nothing per-request, ever — never a prompt, response, model name, cost, work_id, project name, or anything about the traffic this install actually handles.

Off by default, and inert with no endpoint configured, regardless of the on/off toggle. UsagePingConfig.enabled defaults to False; UsagePingConfig.endpoint defaults to None and this package ships with no built-in default endpoint — there is nothing for a fresh install to ping even if a user (or a future release with a different default) sets enabled: true, until an operator explicitly configures usage_ping.endpoint. usage_ping/client.py’s maybe_send_event checks endpoint before anything else, including before touching the local state file — an unconfigured install does zero work per call, not just zero network.

A separate, mutable on/off state, not just the config file. The dashboard’s Settings toggle and inferrail telemetry enable|disable need to flip this live, from a process that may not be the one that loaded inferrail.yaml. usage_ping/state.py’s usage-ping-state.json (under the OS app-data dir, alongside receipts.db/budgets.db) is the source of truth once it exists; the config file’s usage_ping.enabled only seeds its first value. endpoint, by contrast, is never user-settable at runtime — it stays a config-file/operator decision. Letting a dashboard visitor redirect anonymous pings to an arbitrary URL would be a real abuse surface for no real benefit; the toggle only ever turns sending on or off against whatever endpoint the operator chose.

Each of the four events fires at most once per install, ever, via an idempotent local marker in the same state file (mark_event_sent_if_new) — not a full lock (a plain read-modify-write), so two processes racing at the exact same instant could in principle both send once; the worst case is one duplicate anonymous event, never a correctness or privacy problem.

Sending never blocks, slows, or can fail the request path. maybe_send_event does its (cheap, local) check-and-mark synchronously, then spawns a daemon thread for the actual httpx.post (3s timeout) — the caller never waits on it, and any exception in that thread (offline, DNS failure, timeout, non-2xx) is caught and logged at debug level only, never raised or surfaced.

Integration points, all only under --app-mode (the toggle only exists on the app-mode dashboard, so there is nothing to activate without it — matching budgets/the local control API’s own --app-mode-only scoping):

Independently verifiable, not just documented: inferrail telemetry preview prints the exact JSON body for every event, built from the raw install_id/OS/version this install would actually use, without sending anything — so nobody has to trust this ADR or docs/privacy/usage-ping.md’s prose to know what the payload is.

The receiver is proposed and built, but deployment is a human action. hosted/usage_ping/service.py is a small FastAPI receiver (same pattern as hosted/ap_exceptions): one unauthenticated POST /ping (the payload is harmless and anonymous, so an API key would only add friction), request-size limit, per-IP rate limit, a kill switch, and no persistence of the connecting IP address at all. This ADR proposes self-hosting it on Render’s free tier (the same platform hosted/ap_exceptions already uses, so no new vendor account is needed) over a third-party analytics service, since Inferrail’s own positioning is “your prompts never leave your machine’s control” — routing anonymous install pings through a third party sits awkwardly against that even though the payload itself is harmless. See PROGRESS.md’s “HUMAN ACTION NEEDED” for the exact deploy steps and what this session could not do (create the Render service, choose the real URL).

Consequences