Skip to content
Open
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
7 changes: 7 additions & 0 deletions .github/workflows/test-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,12 @@ jobs:
- name: Clippy
run: cargo clippy -p clickhousectl -- -D warnings

# Telemetry must compile out entirely for distro packagers.
- name: Check without default features (telemetry compiled out)
run: cargo check -p clickhousectl --no-default-features

- name: Clippy without default features
run: cargo clippy -p clickhousectl --all-targets --no-default-features -- -D warnings

- name: Test
run: cargo test -p clickhousectl
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,40 @@ clickhousectl update --check

The CLI checks for updates in the background (at most once per 24 hours) and caches the result. When a newer version is available, a one-line notice is printed to stderr at the end of every command that produces human-readable output. JSON output (`--json` or a detected coding agent) is never affected, so machine consumers stay clean. Running `clickhousectl update` clears the cached notice.

## Telemetry

`clickhousectl` collects anonymous usage data to help us understand which commands matter and improve the CLI. Full details: <https://clickhouse.com/docs/interfaces/cli#telemetry>.

Each event contains exactly:

- the command path (e.g. `local start`)
- the **names** of the flags passed (e.g. `json`, `org-id`) — never flag values, never positional arguments
- the exit code (`gh`-style: 0 success, 1 error, 2 cancelled, 4 auth required)
- the CLI version, OS, and architecture
- 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.


Nothing is ever sent before you have seen the notice: the first run prints a one-time notice to stderr, records that it was shown in `~/.clickhouse/telemetry.json`, and sends nothing. Sending starts from the following run. The send happens in a short-lived detached process, so command latency is unaffected even when the endpoint is unreachable.

Opt out any of these ways:

```bash
# Persistently, per machine
clickhousectl telemetry disable

# Check the current state
clickhousectl telemetry status

# Per environment/shell (https://consoledonottrack.com)
export DO_NOT_TRACK=1
```

To see exactly what would be sent without sending it, set `CHCTL_TELEMETRY_DEBUG=1` — the payload is printed to stderr and nothing leaves the machine.

Distribution packagers can compile telemetry out entirely (including the `telemetry` subcommand) with `cargo build --no-default-features`.

## Cloud integration testing

Cloud API integration is tested against a real ClickHouse Cloud workspace via the library crate. All changes to cloud commands must pass CI testing before merge. Tests live in three binaries, each a single `#[tokio::test]` lifecycle:
Expand Down
5 changes: 5 additions & 0 deletions crates/clickhousectl/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ pkg-fmt = "tgz"
bin-dir = "{ name }-{ target }-v{ version }/{ bin }{ binary-ext }"

[features]
default = ["telemetry"]
# Anonymous usage telemetry (first-run notice, `telemetry` subcommand, detached
# send). On by default; distro packagers can compile it out entirely with
# `--no-default-features`.
telemetry = []
# Forwards to the library feature of the same name: when enabled, deprecated API
# fields are present on the response structs and surfaced in CLI output. Off by
# default so the CLI never shows a field the API has deprecated.
Expand Down
58 changes: 58 additions & 0 deletions crates/clickhousectl/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,41 @@ CONTEXT FOR AGENTS:
Self-update command. Downloads the latest clickhousectl release from GitHub and replaces the
current binary. Use --check to see if an update is available without installing.")]
Update(UpdateArgs),

/// Manage anonymous usage telemetry
#[cfg(feature = "telemetry")]
#[command(after_help = "\
CONTEXT FOR AGENTS:
clickhousectl collects anonymous usage data: command name, flag names (never values or
arguments), success/failure, version, OS/arch, and CI/agent detection. No user or machine IDs.
Opt out with `clickhousectl telemetry disable` or DO_NOT_TRACK=1.
Details: https://clickhouse.com/docs/interfaces/cli#telemetry")]
Telemetry(TelemetryArgs),
}

#[cfg(feature = "telemetry")]
#[derive(Args, Debug)]
pub struct TelemetryArgs {
#[command(subcommand)]
pub command: TelemetryCommands,
}

#[cfg(feature = "telemetry")]
#[derive(Subcommand, Debug)]
pub enum TelemetryCommands {
/// Enable anonymous usage telemetry
Enable,
/// Disable anonymous usage telemetry
Disable,
/// Show whether telemetry is enabled and why
///
/// On a machine that has never seen the first-run notice, this reports
/// "not yet configured" and then completes the first run itself (writes
/// the marker file and prints the notice).
Status,
/// (internal) Fire one telemetry POST from CHCTL_TELEMETRY_PAYLOAD and exit
#[command(hide = true)]
Send,
}

#[derive(Args, Debug)]
Expand Down Expand Up @@ -160,4 +195,27 @@ mod tests {
assert!(args.global);
assert_eq!(args.agents, vec!["claude", "codex", "agents"]);
}

#[cfg(feature = "telemetry")]
#[test]
fn parses_telemetry_subcommands() {
for (arg, expected) in [
("enable", "Enable"),
("disable", "Disable"),
("status", "Status"),
("send", "Send"),
] {
let cli = Cli::try_parse_from(["clickhousectl", "telemetry", arg]).unwrap();
let Commands::Telemetry(args) = cli.command else {
panic!("expected telemetry command for {arg}");
};
assert_eq!(format!("{:?}", args.command), expected);
}
}

#[cfg(feature = "telemetry")]
#[test]
fn telemetry_requires_a_subcommand() {
assert!(Cli::try_parse_from(["clickhousectl", "telemetry"]).is_err());
}
}
49 changes: 46 additions & 3 deletions crates/clickhousectl/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ mod init;
mod local;
mod paths;
mod skills;
#[cfg(feature = "telemetry")]
mod telemetry;
mod update;
mod user_agent;
mod version_manager;

use clap::Parser;
use clap::error::ErrorKind;
use clap::{CommandFactory, FromArgMatches};
use cli::{
ActivityCommands, AuthCommands, BackupCommands, BackupConfigCommands, Cli, ClickPipeCommands,
ClickPipeCreateCommands, ClickPipeSettingsCommands, CloudArgs, CloudCommands, Commands,
Expand All @@ -32,8 +34,12 @@ async fn main() {
// libc's environ.
dotenv::init();

let cli = match Cli::try_parse() {
Ok(cli) => cli,
// Parse via ArgMatches (rather than `Cli::try_parse()`) so the telemetry
// capture below can read the command path and passed-flag *names* from the
// clap definitions — argument values are never consulted.
let mut cmd = Cli::command();
let matches = match cmd.try_get_matches_from_mut(std::env::args_os()) {
Ok(matches) => matches,
Err(e) => {
match e.kind() {
// --version always hits the network to refresh the cache + timer,
Expand All @@ -50,11 +56,32 @@ async fn main() {
update::print_cached_update_notice();
std::process::exit(0);
}
// Parse errors exit here, before telemetry capture: a mistyped
// invocation never produces an event.
_ => e.exit(),
}
}
};

#[cfg(feature = "telemetry")]
let telemetry_invocation = telemetry::capture(&cmd, &matches);

let cli = Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit());

// The hidden send child does exactly one POST and exits: no update-cache
// refresh, no dispatch, and no telemetry hook of its own (a send can never
// trigger another send).
#[cfg(feature = "telemetry")]
if matches!(
cli.command,
Commands::Telemetry(cli::TelemetryArgs {
command: cli::TelemetryCommands::Send
})
) {
telemetry::run_child_send().await;
std::process::exit(0);
}

// Spawn a background task to refresh the update cache for non-update
// commands. The refresh is gated to one network call per 24h; the notice
// below is driven off whatever the cache currently holds.
Expand Down Expand Up @@ -92,6 +119,11 @@ async fn main() {
update::print_cached_update_notice();
}

// Consent is evaluated here, after the command ran, so `telemetry disable`
// silences its own event and `telemetry enable` sends one.
#[cfg(feature = "telemetry")]
telemetry::finalize(telemetry_invocation, exit_code);

std::process::exit(exit_code);
}

Expand All @@ -105,6 +137,8 @@ fn command_json_flag(cmd: &Commands) -> Option<bool> {
Commands::Local(args) => Some(args.json),
Commands::Cloud(args) => Some(args.json),
Commands::Skills(_) => Some(false),
#[cfg(feature = "telemetry")]
Commands::Telemetry(_) => Some(false),
}
}

Expand Down Expand Up @@ -132,6 +166,8 @@ async fn run(cmd: Commands) -> Result<()> {
Commands::Skills(args) => run_skills(args).await,
Commands::Cloud(args) => run_cloud(*args).await,
Commands::Update(args) => run_update(args).await,
#[cfg(feature = "telemetry")]
Commands::Telemetry(args) => telemetry::run_command(args.command),
}
}

Expand Down Expand Up @@ -1273,6 +1309,7 @@ async fn run_postgres(
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
use cloud::{CloudError, CloudErrorKind};

#[test]
Expand Down Expand Up @@ -1317,6 +1354,12 @@ mod tests {
);
// The update command never surfaces the notice.
assert_eq!(command_json_flag(&parse(&["clickhousectl", "update"])), None);
// Telemetry management commands are human-readable output.
#[cfg(feature = "telemetry")]
assert_eq!(
command_json_flag(&parse(&["clickhousectl", "telemetry", "status"])),
Some(false)
);
}

#[test]
Expand Down
Loading