Maintainers of a CLI fly mostly blind. You know how many times the package was downloaded, but not which commands people use, which ones fail, how long they take, or whether anybody still runs the subcommand you would like to delete. Usage telemetry answers those questions, and that is why so many developer tools collect it. It is also the feature most likely to turn a security review, a corporate rollout or a community thread against you — because a CLI runs with the user's credentials, inside their source tree, often in CI with secrets in the environment. The only telemetry worth building is telemetry users would agree to if they read exactly what it sends. This guide builds that: opt-in consent, a minimal and documented event, an inspection mode, sensible defaults for CI and DO_NOT_TRACK, and delivery that never delays a command. It completes the update checks topic.
Prerequisites
- Python 3.10+, Typer or Click,
httpxandplatformdirs. - An HTTPS endpoint you control that accepts a small JSON document — your own service, or an analytics product configured to store nothing else.
- A privacy statement you are willing to publish. Write it first; the code should implement what it says.
Decide what you will never collect
The design starts with a list of exclusions, because those are what users and reviewers care about.
Never collect arguments or option values (they contain paths, hostnames, ticket numbers and occasionally passwords), file paths or names, environment variables, usernames, hostnames or IP-derived identifiers, error messages (they echo inputs), or anything from the working directory. What remains is still useful: the command name, the tool version, Python version, OS family, whether the run succeeded, the exception class if it failed, the duration rounded to tens of milliseconds, whether it ran in CI, and a random installation ID so you can count users without identifying them.
Write that list into the documentation as a table of fields, and make the code produce exactly that table — nothing more.
Consent and its states
Consent has three states, not two: unknown (never asked), enabled and disabled. Unknown behaves like disabled. The tool may ask once, interactively, and only on a terminal; it must never ask in CI or in a pipe.
Environment variables override stored consent in one direction only: DO_NOT_TRACK=1 (a cross-tool convention) and a tool-specific MYTOOL_TELEMETRY=0 turn telemetry off regardless of the config file, and no variable turns it on in a way that bypasses the user's stored choice except a deliberate MYTOOL_TELEMETRY=1 set by whoever controls that environment — useful for an organisation that wants usage data from its own CI.
The recipe
# src/mytool/telemetry.py
from __future__ import annotations
import json
import os
import platform
import sys
import threading
import time
import uuid
from dataclasses import asdict, dataclass
from pathlib import Path
import httpx
ENDPOINT = "https://telemetry.example.com/v1/events"
CI_VARS = ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "TF_BUILD")
@dataclass(frozen=True)
class Event:
"""Every field sent. Keep this in sync with the published documentation."""
install_id: str
command: str
version: str
python: str
os: str
ci: bool
ok: bool
error_type: str | None
duration_ms: int
class Telemetry:
def __init__(self, state_file: Path, version: str, *, endpoint: str = ENDPOINT) -> None:
self.state_file = state_file
self.version = version
self.endpoint = endpoint
self._thread: threading.Thread | None = None
# -- consent ----------------------------------------------------------
def _state(self) -> dict:
try:
return json.loads(self.state_file.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def _write(self, state: dict) -> None:
self.state_file.parent.mkdir(parents=True, exist_ok=True)
self.state_file.write_text(json.dumps(state), encoding="utf-8")
def set_consent(self, enabled: bool) -> None:
state = self._state()
state["consent"] = enabled
state.setdefault("install_id", uuid.uuid4().hex)
self._write(state)
def reset_id(self) -> None:
state = self._state()
state["install_id"] = uuid.uuid4().hex
self._write(state)
def enabled(self) -> bool:
env = os.environ.get("MYTOOL_TELEMETRY")
if os.environ.get("DO_NOT_TRACK", "") not in ("", "0") or env == "0":
return False
if env == "1":
return True # explicitly enabled for this environment
if any(os.environ.get(v) for v in CI_VARS):
return False
return self._state().get("consent") is True
# -- events -----------------------------------------------------------
def build(self, command: str, ok: bool, error: BaseException | None,
started: float) -> Event:
state = self._state()
return Event(
install_id=state.get("install_id", "anonymous"),
command=command,
version=self.version,
python=platform.python_version(),
os=sys.platform,
ci=any(os.environ.get(v) for v in CI_VARS),
ok=ok,
error_type=type(error).__name__ if error else None,
duration_ms=round((time.monotonic() - started) * 100) * 10,
)
def record(self, event: Event) -> None:
payload = asdict(event)
if os.environ.get("MYTOOL_TELEMETRY_DEBUG"):
print(f"telemetry (not sent): {json.dumps(payload)}", file=sys.stderr)
return
self._thread = threading.Thread(target=self._send, args=(payload,), daemon=True)
self._thread.start()
def _send(self, payload: dict) -> None:
try:
httpx.post(self.endpoint, json=payload, timeout=1.0)
except httpx.HTTPError:
pass # telemetry must never surface an error
def flush(self, wait: float = 0.3) -> None:
if self._thread is not None:
self._thread.join(wait)
The Event dataclass is the contract. Because every field is declared there, code review of a telemetry change is a review of one dataclass, and a test can assert that the payload keys match the documented list exactly. Durations are rounded to ten milliseconds so timing data cannot fingerprint a machine. The error type is a class name such as PermissionError, never str(error).
Wiring it into the CLI
# src/mytool/cli.py
import time
import typer
from platformdirs import user_state_path
from mytool.telemetry import Telemetry
app = typer.Typer()
telemetry_app = typer.Typer(help="Manage anonymous usage statistics.")
app.add_typer(telemetry_app, name="telemetry")
TELEMETRY = Telemetry(user_state_path("mytool") / "telemetry.json", "1.4.2")
@app.callback()
def main(ctx: typer.Context) -> None:
"""My tool."""
if not TELEMETRY.enabled() or ctx.invoked_subcommand == "telemetry":
return
started = time.monotonic()
def done() -> None:
error = getattr(ctx, "telemetry_error", None)
TELEMETRY.record(TELEMETRY.build(ctx.invoked_subcommand or "", error is None,
error, started))
TELEMETRY.flush()
ctx.call_on_close(done)
@telemetry_app.command("enable")
def enable() -> None:
TELEMETRY.set_consent(True)
typer.echo("Anonymous usage statistics enabled. See https://example.com/telemetry")
@telemetry_app.command("disable")
def disable() -> None:
TELEMETRY.set_consent(False)
typer.echo("Anonymous usage statistics disabled.")
@telemetry_app.command("status")
def status() -> None:
state = "enabled" if TELEMETRY.enabled() else "disabled"
typer.echo(f"telemetry is {state}")
The callback skips the telemetry subcommands themselves, so turning telemetry off is never itself reported. Recording failures needs one more hook: catch exceptions at the top-level entry point, store them on the context (or a module variable) before re-raising, and let done() read the class name. The exception hierarchy guide shows where that top-level handler lives.
UX considerations
- Ask once, or not at all. If you prompt, do it on the first interactive run, explain in two lines, default to "no", and store the answer either way. Never prompt in CI, in a pipe or under
--no-input. - Make the payload visible.
MYTOOL_TELEMETRY_DEBUG=1prints the exact event instead of sending it. Users and security reviewers will use it. - Document every field in a page linked from the enable message and the README, and version that page with the tool.
- Honour
DO_NOT_TRACK. It costs one line and signals good faith. - Separate it from update checks. A version check sends no user data; telemetry does. Keep separate switches so disabling one does not silently disable the other.
- Plan for deletion. Offer
telemetry reset-idand a way to request deletion of data tied to an ID. Some jurisdictions require it; everyone appreciates it.
Testing the behaviour
Test consent logic exhaustively and assert the payload shape against the documented list:
# tests/test_telemetry.py
import time
from dataclasses import fields
import pytest
from mytool.telemetry import CI_VARS, Event, Telemetry
DOCUMENTED = {"install_id", "command", "version", "python", "os", "ci", "ok",
"error_type", "duration_ms"}
@pytest.fixture
def tel(tmp_path, monkeypatch):
for var in (*CI_VARS, "DO_NOT_TRACK", "MYTOOL_TELEMETRY", "MYTOOL_TELEMETRY_DEBUG"):
monkeypatch.delenv(var, raising=False)
return Telemetry(tmp_path / "telemetry.json", "1.4.2", endpoint="https://t.invalid/e")
def test_payload_matches_documentation():
assert {f.name for f in fields(Event)} == DOCUMENTED
def test_disabled_until_consent(tel):
assert tel.enabled() is False
tel.set_consent(True)
assert tel.enabled() is True
@pytest.mark.parametrize("var,value", [("DO_NOT_TRACK", "1"), ("MYTOOL_TELEMETRY", "0"),
("CI", "true")])
def test_environment_turns_it_off(tel, monkeypatch, var, value):
tel.set_consent(True)
monkeypatch.setenv(var, value)
assert tel.enabled() is False
def test_error_is_reduced_to_its_class(tel):
event = tel.build("deploy", False, PermissionError("/home/ann/secret.txt"), time.monotonic())
assert event.error_type == "PermissionError"
assert "secret" not in repr(event)
def test_debug_mode_prints_instead_of_sending(tel, monkeypatch, capsys):
monkeypatch.setenv("MYTOOL_TELEMETRY_DEBUG", "1")
tel.record(tel.build("status", True, None, time.monotonic()))
assert '"command": "status"' in capsys.readouterr().err
def test_send_failures_are_silent(tel):
tel.record(tel.build("status", True, None, time.monotonic()))
tel.flush(wait=2) # endpoint does not resolve; nothing raises
The first test is the most valuable one in the file. Anyone adding a field has to update DOCUMENTED in the same change, which is a natural prompt to update the public documentation too.
Conclusion
Responsible telemetry is a narrow feature: opt-in consent with three states, an event dataclass that is the whole payload, environment overrides that only ever turn it off (except a deliberate per-environment opt-in), a debug mode that shows the payload, and delivery in a daemon thread with a one-second timeout. Build it that way and it can survive a security review — and give you real answers about how the tool is used.
Frequently asked questions
Is opt-out telemetry ever acceptable?
For a command-line developer tool it is a frequent source of controversy, and in some regulatory contexts consent is required anyway. Opt-in yields less data, but data you can use without apologising for it. If you ship opt-out, at minimum announce it prominently on first run and honour every off switch.
Should I use a third-party analytics SDK?
Most analytics SDKs collect more than the fields above by default — device identifiers, IP addresses, session data — and add import time to every run. Posting your own small JSON document to an endpoint you control keeps the payload exactly as documented.
How do I count unique users without identifying them?
The random installation ID does that: it is generated locally, carries no information, and can be reset by the user. Do not derive it from a hostname, MAC address or username, and do not store IP addresses alongside it on the server.
Can events be batched to reduce requests?
Yes: append events to a local file and send a batch once a day from the same background mechanism as the update check. Cap the file size and drop old events, so a machine that is always offline does not accumulate data indefinitely.