Skip to content

Add minimal, anonymous usage telemetry#292

Open
sdairs wants to merge 4 commits into
mainfrom
issue-283-usage-telemetry
Open

Add minimal, anonymous usage telemetry#292
sdairs wants to merge 4 commits into
mainfrom
issue-283-usage-telemetry

Conversation

@sdairs

@sdairs sdairs commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #283

Default-on anonymous usage telemetry with a Homebrew-style consent model: nothing is ever sent before the first-run notice has been shown.

Consent & state

  • ~/.clickhouse/telemetry.json ({"disabled": false}) doubles as the first-run marker. First run: write file → print notice to stderr (regardless of TTY) → send nothing. The notice is gated on the write succeeding, so an unwritable config dir fails open to disabled — no send, no error, no repeated notice.
  • DO_NOT_TRACK (donottrack.sh spec: set and not ""/"0"/"false") is checked first and overrides everything.
  • clickhousectl telemetry enable|disable|status manages the file. Consent is evaluated after the command runs, so telemetry disable silences its own event and telemetry enable sends one.

Payload

{"command":"local list","flags":["json"],"exit_code":0,"is_agent":true,"agent":"claude-code","ci":false,"version":"0.3.1","os":"macos","arch":"aarch64"}

command (path), flags (long names only), exit_code (gh-style: 0 success, 1 error, 2 cancelled, 4 auth required), is_agent + agent (canonical id from is-ai-agent, null for humans), ci (CI env), version, os, arch. No install ID, no fingerprinting. Agent, version, and exit code are set client-side from structured values rather than extracted worker-side from headers.

Values are structurally unreachable: main now parses via ArgMatches, and telemetry::capture walks only arg ids and Arg metadata (never get_one/get_raw), skipping positionals and non-command-line sources. A unit fixture asserts a payload built from cloud service get SECRET-ID --org-id SECRET-ORG contains no SECRET, and an integration test asserts the same over the wire.

Transport

Detached child process (telemetry send, hidden subcommand): payload via child env var, all stdio nulled, parent never waits; child fires one POST with a 2s timeout and dies silently on any failure. Measured ~0.3s command wall-clock with the endpoint blackholed. The child is short-circuited in main before dispatch, so a send can never trigger another send.

Every POST carries the canonical User-Agent (clickhousectl/<version>, optionally (agent=<id>)) via the shared http::client_builder(), pinned by a wire-level test — the ingest worker can reject anything without the clickhousectl/ prefix.

Transparency & opt-outs

  • CHCTL_TELEMETRY_DEBUG=1 prints the exact payload to stderr and sends nothing.
  • CHCTL_TELEMETRY_URL overrides the endpoint (tests / local worker dev).
  • Cargo feature telemetry (default-on): --no-default-features compiles the whole thing out including the subcommand — enforced by new CI steps.
  • README section documenting all of the above.

Tests

  • Unit: consent state machine (DNT precedence, first-run, unwritable dir, corrupt file), donottrack truthiness, payload key set + agent-field consistency, capture fixtures (no-leak, global-flag dedupe, default-value exclusion).
  • Integration (tests/telemetry_test.rs, subprocess + wiremock + sandboxed HOME): first-run notice/marker/no-send, payload shape + User-Agent prefix, failure exit code + positional non-leak, DNT silence, disable/enable persistence, debug mode, unwritable-HOME fail-open, parent-never-waits latency.
  • Existing subprocess tests now set DO_NOT_TRACK=1 so test runs never write real state or POST to production.

Verified end-to-end against the telemetry worker running locally (wrangler dev): first run silent, subsequent runs delivered events the worker accepted and inserted (200 OK).

Worker-side follow-ups (separate repo, in dev)

  • exit_code (number) replaces success (bool) — needs a numeric coercer + column (success is derivable as exit_code = 0).
  • is_agent is now a payload bool (was agent), and agent is the agent id string (null for humans) — needs a column + coercion update in toRow.
  • Optionally reject requests whose User-Agent doesn't start with clickhousectl/.

Pre-release TODO (not this PR)

🤖 Generated with Claude Code

Default-on telemetry with a Homebrew-style consent model (issue #283):
nothing is ever sent before the first-run notice has been shown.

- State/marker: ~/.clickhouse/telemetry.json ({"disabled": false}); first
  run writes the file, prints a notice to stderr, and sends nothing. The
  notice is gated on the write succeeding, so an unwritable config dir
  fails open to disabled with no repeated notice.
- DO_NOT_TRACK (donottrack.sh) overrides everything; CHCTL_TELEMETRY_DEBUG=1
  prints the exact payload to stderr and sends nothing.
- Payload is command path + flag NAMES only, built by walking the clap
  Command/ArgMatches metadata (never get_one/get_raw), so leaking values
  or positionals is structurally impossible. Plus success bool, agent/CI
  detection, version, os/arch.
- Transport is a detached `telemetry send` child (hidden subcommand, all
  stdio nulled, 2s timeout, parent never waits): zero latency impact even
  with the endpoint blackholed.
- `clickhousectl telemetry enable|disable|status` manages consent; the
  whole feature (including the subcommand) compiles out with
  --no-default-features for distro packagers, enforced in CI.
- Existing subprocess tests now set DO_NOT_TRACK=1 so test runs never
  write real state or post to the production endpoint.

Verified end-to-end against the telemetry worker running locally via
wrangler dev: first run silent, subsequent runs deliver events that the
worker accepts and inserts (200 OK).

Closes #283

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sdairs sdairs requested a review from iskakaushik as a code owner July 7, 2026 16:33
TelemetryCommands::Disable => {
set_disabled(true)?;
println!("Telemetry disabled.");
Ok(())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Enable sends before first-run notice

High Severity

The telemetry enable and telemetry disable commands write the telemetry state file (telemetry.json) before the finalize hook. This bypasses the State::Missing check, causing the first-run notice to be skipped. As a result, telemetry can be enabled and potentially sent, or disabled, without the user ever seeing the initial consent notice.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7fddcf3. Configure here.

/// the real environment).
type EnvLookup<'a> = &'a dyn Fn(&str) -> Option<String>;

fn real_env_lookup(key: &str) -> Option<String> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/telemetry.rs:104

real_env_lookup uses std::env::var(key).ok(), which returns None when the environment variable is set but its value contains non-UTF-8 bytes. On Unix, if DO_NOT_TRACK is set to non-UTF-8 bytes, decide treats the opt-out variable as absent and telemetry proceeds despite the user setting it. Use std::env::var_os so non-Unicode values are still detected.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/clickhousectl/src/telemetry.rs around line 104:

`real_env_lookup` uses `std::env::var(key).ok()`, which returns `None` when the environment variable is set but its value contains non-UTF-8 bytes. On Unix, if `DO_NOT_TRACK` is set to non-UTF-8 bytes, `decide` treats the opt-out variable as absent and telemetry proceeds despite the user setting it. Use `std::env::var_os` so non-Unicode values are still detected.

if env_truthy(env(DNT_ENV)) {
return Action::Silent;
}
match load_state_from(path) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/telemetry.rs:242

On a fresh machine, concurrent clickhousectl invocations can send telemetry before the first-run consent notice is ever shown. In decide(), the State::Missing arm writes telemetry.json (marking the notice as "shown") before finalize() actually prints the notice. A second process started concurrently sees the marker file, reads State::Enabled, and returns Action::Send(...), transmitting telemetry even though no user has been notified yet. Consider deferring the state write until after the notice has been printed, or using a distinct intermediate state so that Send is not reachable until the notice has actually been displayed.

Also found in 1 other location(s)

README.md:852

The claim at README.md line 852 that "Nothing is ever sent before you have seen the notice" is false for clickhousectl telemetry enable on a fresh machine. main calls telemetry::finalize after command execution, telemetry enable writes ~/.clickhouse/telemetry.json in set_disabled(false), and then decide() sees State::Enabled and returns Action::Send without ever printing the first-run notice. A first-time telemetry enable invocation therefore sends an event even though the README says first run always sends nothing.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/clickhousectl/src/telemetry.rs around line 242:

On a fresh machine, concurrent `clickhousectl` invocations can send telemetry before the first-run consent notice is ever shown. In `decide()`, the `State::Missing` arm writes `telemetry.json` (marking the notice as "shown") before `finalize()` actually prints the notice. A second process started concurrently sees the marker file, reads `State::Enabled`, and returns `Action::Send(...)`, transmitting telemetry even though no user has been notified yet. Consider deferring the state write until after the notice has been printed, or using a distinct intermediate state so that `Send` is not reachable until the notice has actually been displayed.

Also found in 1 other location(s):
- README.md:852 -- The claim at `README.md` line 852 that "Nothing is ever sent before you have seen the notice" is false for `clickhousectl telemetry enable` on a fresh machine. `main` calls `telemetry::finalize` after command execution, `telemetry enable` writes `~/.clickhouse/telemetry.json` in `set_disabled(false)`, and then `decide()` sees `State::Enabled` and returns `Action::Send` without ever printing the first-run notice. A first-time `telemetry enable` invocation therefore sends an event even though the README says first run always sends nothing.

sdairs and others added 2 commits July 7, 2026 18:35
… prefix

The send child already goes through http::client_builder(), so every
telemetry POST carries the same User-Agent as all other outbound HTTP:
`clickhousectl/<version>`, optionally followed by an ` (agent=<id>)`
comment. Assert it wire-level so the ingest worker can safely reject
traffic without that prefix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The CLI already holds the detected agent as a structured value via
is-ai-agent, so set both facts client-side — is_agent (bool) and agent
(canonical id, e.g. "claude-code"; null for humans) — rather than having
the ingest worker string-parse them out of the User-Agent header. Same
for version: the payload value from CARGO_PKG_VERSION stays canonical.
The User-Agent still carries the same facts for prefix-based request
filtering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread README.md
- whether it ran in CI (`CI` env var)
- whether it ran under a detected coding agent, and if so which one (e.g. `claude-code`)

There is no install ID, no device ID, and no fingerprinting of any kind. The payload is built from the clap command definitions rather than the raw command line, so leaking an argument value is structurally impossible — the code that builds the event has no access to values at all.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium README.md:851

Line 851 states there is no install ID, device ID, or fingerprinting of any kind. That claim is false: telemetry requests built by run_child_send() go through http::client_builder(), which unconditionally adds agent-session-id and traceparent headers from is_ai_agent::detect(). These headers expose stable session/conversation identifiers that let the backend correlate multiple telemetry posts, contradicting the documented promise that events carry no IDs or fingerprinting.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @README.md around line 851:

Line 851 states there is no install ID, device ID, or fingerprinting of any kind. That claim is false: telemetry requests built by `run_child_send()` go through `http::client_builder()`, which unconditionally adds `agent-session-id` and `traceparent` headers from `is_ai_agent::detect()`. These headers expose stable session/conversation identifiers that let the backend correlate multiple telemetry posts, contradicting the documented promise that events carry no IDs or fingerprinting.

The finalize hook already receives the exact exit code the process is
about to exit with (Error::exit_code: 0 success, 1 error, 2 cancelled,
4 auth required), so send that: success is derivable (exit_code = 0)
but the code also distinguishes cancellations and auth failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 37d248d. Configure here.

"Could not determine home directory",
))
})?;
save_state_to(&path, disabled).map_err(Error::Io)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

DNT still writes telemetry file

Medium Severity

DO_NOT_TRACK is honored in decide()/finalize() but not in telemetry enable or telemetry disable. Those subcommands always call set_disabled() and write ~/.clickhouse/telemetry.json, contradicting the module comment and PR text that DNT overrides everything with no file write.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 37d248d. Configure here.

} else {
Action::Send(json)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Parallel first runs may send

Medium Severity

decide reads and writes telemetry.json without synchronization. Two concurrent processes that both start with a missing marker can each pass the first-run path; after one writes the enabled marker, the other may still reach State::Enabled and send on what is effectively still the user’s first wave of runs, before a separate “following run.”

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 37d248d. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add minimal, anonymous usage telemetry

1 participant