Runtime

Opt-In Usage Telemetry for Python CLIs Done Responsibly

Collect minimal, opt-in usage events from a Python CLI: explicit consent, a documented payload, a debug mode, DO_NOT_TRACK, and sending that never blocks.

Updated

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, httpx and platformdirs.
  • 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.

What an event may contain Data a command line telemetry event may include compared with data it must never include. What an event may contain Field Collect? Instead Command name yes — Arguments, option values never nothing Exception class name only not the message Duration yes rounded to 10 ms User or host identity never a random install ID Messages and arguments echo user input — the one thing telemetry must not carry.

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 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.

Consent has three states Telemetry consent starts unknown, which behaves as disabled, and moves to enabled or disabled only by an explicit user choice. Consent has three states Unknown behaves as off Asked once interactive only Enabled user opted in Disabled remembered first TTY run yes or no DO_NOT_TRACK and MYTOOL_TELEMETRY=0 override an enabled state; nothing silently enables it.

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.

Inspecting telemetry Terminal session enabling telemetry, printing the exact payload in debug mode, and checking status with DO_NOT_TRACK set. Inspecting telemetry bash $ mytool telemetry enable Anonymous usage statistics enabled. See https://example.com/telemetry $ MYTOOL_TELEMETRY_DEBUG=1 mytool status all systems nominal telemetry (not sent): {"command": "status", "ok": true, ...} $ DO_NOT_TRACK=1 mytool telemetry status telemetry is disabled The debug mode shows exactly the documented fields — and sends nothing.

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=1 prints 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-id and a way to request deletion of data tied to an ID. Some jurisdictions require it; everyone appreciates it.
Who decides, in order Precedence of telemetry switches, from environment variables that force it off down to the stored user choice. Who decides, in order DO_NOT_TRACK=1 or MYTOOL_TELEMETRY=0 env always off, whatever else is set MYTOOL_TELEMETRY=1 env deliberate opt-in for this environment, e.g. a company CI CI detected auto off unless opted in above Stored consent state file telemetry enable / disable No choice yet default off Off-switches sit at the top so no lower layer can override them.

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.