#![cfg(feature = "telemetry")]
use std::path::PathBuf;
use std::process::{Command, Output};
use std::time::{Duration, Instant};
use serde_json::Value;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn clickhousectl_binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_clickhousectl"))
}
struct Sandbox {
home: tempfile::TempDir,
mock: MockServer,
}
impl Sandbox {
async fn new() -> Self {
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/telemetry"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"ok": true})))
.mount(&mock)
.await;
Sandbox {
home: tempfile::tempdir().unwrap(),
mock,
}
}
fn state_path(&self) -> PathBuf {
self.home.path().join(".clickhouse").join("telemetry.json")
}
fn write_state(&self, disabled: bool) {
let dir = self.state_path();
std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
std::fs::write(&dir, format!(r#"{{"disabled":{disabled}}}"#)).unwrap();
}
fn command(&self, args: &[&str]) -> Command {
let mut cmd = Command::new(clickhousectl_binary());
cmd.args(args)
.env("HOME", self.home.path())
.env(
"CHCTL_TELEMETRY_URL",
format!("{}/v1/telemetry", self.mock.uri()),
)
.env_remove("DO_NOT_TRACK")
.env_remove("CHCTL_TELEMETRY_DEBUG")
.env_remove("CHCTL_TELEMETRY_PAYLOAD")
.env_remove("CI");
cmd
}
fn run(&self, args: &[&str]) -> Output {
self.command(args).output().expect("failed to spawn binary")
}
fn run_with_closed_stderr(&self, args: &[&str]) -> Output {
let (reader, writer) = std::io::pipe().expect("failed to create pipe");
drop(reader);
self.command(args)
.stderr(writer)
.output()
.expect("failed to spawn binary")
}
async fn wait_for_requests(&self, n: usize) -> Vec<Value> {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let requests = self.mock.received_requests().await.unwrap_or_default();
if requests.len() >= n {
return requests
.iter()
.map(|r| serde_json::from_slice(&r.body).expect("payload must be JSON"))
.collect();
}
assert!(
Instant::now() < deadline,
"telemetry event did not arrive within 5s (saw {})",
requests.len()
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
async fn assert_no_requests(&self) {
tokio::time::sleep(Duration::from_millis(750)).await;
let requests = self.mock.received_requests().await.unwrap_or_default();
assert!(
requests.is_empty(),
"expected no telemetry, saw: {:?}",
requests.iter().map(|r| &r.body).collect::<Vec<_>>()
);
}
}
fn stderr_of(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
fn stdout_of(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
#[tokio::test]
async fn first_run_writes_marker_prints_notice_sends_nothing() {
let sandbox = Sandbox::new().await;
let output = sandbox.run(&["local", "list"]);
assert!(output.status.success());
let stderr = stderr_of(&output);
assert!(
stderr.contains("anonymous usage data") && stderr.contains("telemetry disable"),
"first run must print the notice, got stderr: {stderr}"
);
assert_eq!(
std::fs::read_to_string(sandbox.state_path()).unwrap(),
r#"{"disabled":false}"#
);
sandbox.assert_no_requests().await;
let output = sandbox.run(&["local", "list"]);
assert!(!stderr_of(&output).contains("anonymous usage data"));
}
#[tokio::test]
async fn enabled_run_sends_payload_with_expected_shape() {
let sandbox = Sandbox::new().await;
sandbox.write_state(false);
let output = sandbox.run(&["local", "list"]);
assert!(output.status.success());
let payloads = sandbox.wait_for_requests(1).await;
let event = &payloads[0];
assert_eq!(event["command"], "local list");
assert!(event["flags"].as_array().unwrap().is_empty());
assert_eq!(event["exit_code"], 0);
assert!(event["is_agent"].is_boolean());
assert_eq!(
event["is_agent"].as_bool().unwrap(),
event["agent"].is_string(),
"is_agent and agent must come from the same detection: {event}"
);
assert_eq!(event["ci"], false);
assert_eq!(event["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(event["os"], std::env::consts::OS);
assert_eq!(event["arch"], std::env::consts::ARCH);
let requests = sandbox.mock.received_requests().await.unwrap();
let ua = requests[0]
.headers
.get("user-agent")
.expect("telemetry POST must carry a User-Agent")
.to_str()
.unwrap();
let prefix = format!("clickhousectl/{}", env!("CARGO_PKG_VERSION"));
assert!(
ua == prefix || ua.starts_with(&format!("{prefix} (")),
"unexpected User-Agent: {ua}"
);
}
#[tokio::test]
async fn no_agent_correlation_headers_on_the_wire() {
let sandbox = Sandbox::new().await;
sandbox.write_state(false);
let output = sandbox
.command(&["local", "list"])
.env_remove("AGENT")
.env("CLAUDECODE", "1")
.env("CLAUDE_CODE_SESSION_ID", "sess-should-never-hit-the-wire")
.env(
"TRACEPARENT",
"00-11111111111111111111111111111111-2222222222222222-01",
)
.output()
.unwrap();
assert!(output.status.success());
let payloads = sandbox.wait_for_requests(1).await;
let event = &payloads[0];
assert_eq!(event["is_agent"], true);
assert_eq!(event["agent"], "claude-code");
let requests = sandbox.mock.received_requests().await.unwrap();
let headers = &requests[0].headers;
assert!(
headers.get("agent-session-id").is_none(),
"telemetry POST must not carry agent-session-id: {headers:?}"
);
assert!(
headers.get("traceparent").is_none(),
"telemetry POST must not carry traceparent: {headers:?}"
);
}
#[tokio::test]
async fn failure_reported_and_positional_value_never_leaks() {
let sandbox = Sandbox::new().await;
sandbox.write_state(false);
let output = sandbox.run(&["local", "remove", "no-such-version-xyz"]);
assert!(!output.status.success());
let payloads = sandbox.wait_for_requests(1).await;
let event = &payloads[0];
assert_eq!(event["command"], "local remove");
assert_eq!(event["exit_code"], 1);
assert_eq!(event["exit_code"], output.status.code().unwrap());
assert_eq!(event["outcome"], "error");
let raw = serde_json::to_string(event).unwrap();
assert!(
!raw.contains("no-such-version-xyz"),
"positional argument leaked into the payload: {raw}"
);
}
#[tokio::test]
async fn flag_names_sent_but_values_never_leak() {
let sandbox = Sandbox::new().await;
sandbox.write_state(false);
let output = sandbox.run(&["local", "--json", "list"]);
assert!(output.status.success());
let payloads = sandbox.wait_for_requests(1).await;
let event = &payloads[0];
assert_eq!(event["command"], "local list");
assert_eq!(event["flags"], serde_json::json!(["json"]));
}
#[tokio::test]
async fn first_run_of_help_prints_notice_and_writes_marker() {
let sandbox = Sandbox::new().await;
let output = sandbox.run(&["--help"]);
assert_eq!(output.status.code(), Some(0));
assert!(stdout_of(&output).contains("Usage:"));
assert!(
stderr_of(&output).contains("anonymous usage data"),
"first --help must print the notice, got stderr: {}",
stderr_of(&output)
);
assert_eq!(
std::fs::read_to_string(sandbox.state_path()).unwrap(),
r#"{"disabled":false}"#
);
sandbox.assert_no_requests().await;
let output = sandbox.run(&["cloud", "--help"]);
assert_eq!(output.status.code(), Some(0));
assert!(!stderr_of(&output).contains("anonymous usage data"));
let payloads = sandbox.wait_for_requests(1).await;
let event = &payloads[0];
assert_eq!(event["command"], "cloud");
assert_eq!(event["flags"], serde_json::json!(["help"]));
assert_eq!(event["outcome"], "help");
assert_eq!(event["exit_code"], 0);
}
#[tokio::test]
async fn bare_invocation_reports_missing_subcommand() {
let sandbox = Sandbox::new().await;
sandbox.write_state(false);
let output = sandbox.run(&[]);
assert_eq!(
output.status.code(),
Some(2),
"clap usage errors keep exit 2"
);
let payloads = sandbox.wait_for_requests(1).await;
let event = &payloads[0];
assert_eq!(event["command"], "");
assert_eq!(event["outcome"], "missing_subcommand");
assert_eq!(event["exit_code"], 2);
}
#[tokio::test]
async fn invalid_subcommand_reports_kind_but_never_the_token() {
let sandbox = Sandbox::new().await;
sandbox.write_state(false);
let output = sandbox.run(&["hallucinated-subcommand-xyz"]);
assert_eq!(output.status.code(), Some(2));
let payloads = sandbox.wait_for_requests(1).await;
let event = &payloads[0];
assert_eq!(event["command"], "");
assert_eq!(event["outcome"], "invalid_subcommand");
let raw = serde_json::to_string(event).unwrap();
assert!(
!raw.contains("hallucinated-subcommand-xyz"),
"raw token leaked into the payload: {raw}"
);
}
#[tokio::test]
async fn typo_carries_definition_derived_suggestion() {
let sandbox = Sandbox::new().await;
sandbox.write_state(false);
let output = sandbox.run(&["cloud", "servce", "list"]);
assert_eq!(output.status.code(), Some(2));
let payloads = sandbox.wait_for_requests(1).await;
let event = &payloads[0];
assert_eq!(event["command"], "cloud");
assert_eq!(event["outcome"], "invalid_subcommand");
assert_eq!(event["suggestion"], "service");
let raw = serde_json::to_string(event).unwrap();
assert!(!raw.contains("servce"), "typo leaked: {raw}");
}
#[tokio::test]
async fn do_not_track_is_fully_silent() {
let sandbox = Sandbox::new().await;
let output = sandbox
.command(&["local", "list"])
.env("DO_NOT_TRACK", "1")
.output()
.unwrap();
assert!(output.status.success());
assert!(!stderr_of(&output).contains("anonymous usage data"));
assert!(
!sandbox.state_path().exists(),
"DO_NOT_TRACK must not write the marker file"
);
sandbox.assert_no_requests().await;
}
#[cfg(unix)]
#[tokio::test]
async fn non_utf8_do_not_track_is_fully_silent() {
use std::os::unix::ffi::OsStringExt;
let sandbox = Sandbox::new().await;
let output = sandbox
.command(&["local", "list"])
.env(
"DO_NOT_TRACK",
std::ffi::OsString::from_vec(vec![0xff, 0xfe]),
)
.output()
.unwrap();
assert!(output.status.success());
assert!(!stderr_of(&output).contains("anonymous usage data"));
assert!(
!sandbox.state_path().exists(),
"non-UTF-8 DO_NOT_TRACK must not write the marker file"
);
sandbox.assert_no_requests().await;
}
#[tokio::test]
async fn disable_persists_and_silences() {
let sandbox = Sandbox::new().await;
sandbox.write_state(false);
let output = sandbox.run(&["telemetry", "disable"]);
assert!(output.status.success());
assert!(stdout_of(&output).contains("Telemetry disabled."));
assert_eq!(
std::fs::read_to_string(sandbox.state_path()).unwrap(),
r#"{"disabled":true}"#
);
let output = sandbox.run(&["local", "list"]);
assert!(output.status.success());
assert!(!stderr_of(&output).contains("anonymous usage data"));
let output = sandbox.run(&["telemetry", "status"]);
assert!(stdout_of(&output).contains("disabled"));
sandbox.assert_no_requests().await;
}
#[tokio::test]
async fn enable_sends_an_event_for_itself() {
let sandbox = Sandbox::new().await;
sandbox.write_state(true);
let output = sandbox.run(&["telemetry", "enable"]);
assert!(output.status.success());
assert!(stdout_of(&output).contains("Telemetry enabled."));
assert!(!stderr_of(&output).contains("DO_NOT_TRACK is set"));
let payloads = sandbox.wait_for_requests(1).await;
assert_eq!(payloads[0]["command"], "telemetry enable");
}
#[tokio::test]
async fn enable_under_do_not_track_warns_that_telemetry_stays_silent() {
let sandbox = Sandbox::new().await;
let output = sandbox
.command(&["telemetry", "enable"])
.env("DO_NOT_TRACK", "1")
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(output.status.code(), Some(0));
assert!(stdout_of(&output).contains("Telemetry enabled."));
let stderr = stderr_of(&output);
assert!(
stderr.contains("DO_NOT_TRACK") && stderr.contains("remain silent"),
"enable under DNT must warn on stderr, got: {stderr}"
);
assert_eq!(
std::fs::read_to_string(sandbox.state_path()).unwrap(),
r#"{"disabled":false}"#
);
sandbox.assert_no_requests().await;
}
#[tokio::test]
async fn debug_mode_prints_payload_without_sending() {
let sandbox = Sandbox::new().await;
sandbox.write_state(false);
let output = sandbox
.command(&["local", "list"])
.env("CHCTL_TELEMETRY_DEBUG", "1")
.output()
.unwrap();
assert!(output.status.success());
let stderr = stderr_of(&output);
assert!(
stderr.contains(r#""command":"local list""#),
"debug mode must print the payload to stderr, got: {stderr}"
);
sandbox.assert_no_requests().await;
}
#[cfg(unix)]
#[tokio::test]
async fn unwritable_home_fails_open_to_silent() {
use std::os::unix::fs::PermissionsExt;
let sandbox = Sandbox::new().await;
let perms = std::fs::Permissions::from_mode(0o555);
std::fs::set_permissions(sandbox.home.path(), perms).unwrap();
for _ in 0..2 {
let output = sandbox.run(&["telemetry", "status"]);
assert!(output.status.success());
assert!(!stderr_of(&output).contains("anonymous usage data"));
assert!(stdout_of(&output).contains("not yet configured"));
}
assert!(!sandbox.state_path().exists());
sandbox.assert_no_requests().await;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(sandbox.home.path(), perms).unwrap();
}
#[tokio::test]
async fn parent_never_waits_for_a_slow_endpoint() {
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/telemetry"))
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(10)))
.mount(&mock)
.await;
let sandbox = Sandbox::new().await;
sandbox.write_state(false);
let started = Instant::now();
let output = sandbox
.command(&["local", "list"])
.env(
"CHCTL_TELEMETRY_URL",
format!("{}/v1/telemetry", mock.uri()),
)
.output()
.unwrap();
let elapsed = started.elapsed();
assert!(output.status.success());
assert!(
elapsed < Duration::from_secs(5),
"parent waited on the telemetry send: {elapsed:?}"
);
}
#[tokio::test]
async fn marker_lives_in_dot_clickhouse_telemetry_json() {
let sandbox = Sandbox::new().await;
let output = sandbox.run(&["local", "list"]);
assert!(output.status.success());
assert!(sandbox.state_path().exists());
let entries: Vec<_> = std::fs::read_dir(sandbox.home.path().join(".clickhouse"))
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
assert!(
entries.contains(&"telemetry.json".to_string()),
"{entries:?}"
);
}
#[tokio::test]
async fn closed_stderr_never_panics_or_bypasses_telemetry() {
let sandbox = Sandbox::new().await;
sandbox.write_state(false);
let cache = sandbox
.home
.path()
.join(".clickhouse")
.join("last_update_check");
std::fs::create_dir_all(cache.parent().unwrap()).unwrap();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
std::fs::write(&cache, format!("{now}\n999.0.0")).unwrap();
let output = sandbox.run_with_closed_stderr(&["--help"]);
assert_eq!(output.status.code(), Some(0), "help must not panic");
let payloads = sandbox.wait_for_requests(1).await;
assert_eq!(payloads[0]["outcome"], "help");
let output = sandbox.run_with_closed_stderr(&["local", "remove", "no-such-version-xyz"]);
assert_eq!(output.status.code(), Some(1), "failure must keep exit 1");
let payloads = sandbox.wait_for_requests(2).await;
assert_eq!(payloads[1]["exit_code"], 1);
let output = {
let (reader, writer) = std::io::pipe().expect("failed to create pipe");
drop(reader);
sandbox
.command(&["telemetry", "enable"])
.env("DO_NOT_TRACK", "1")
.stderr(writer)
.output()
.expect("failed to spawn binary")
};
assert_eq!(output.status.code(), Some(0), "enable must not panic");
assert!(stdout_of(&output).contains("Telemetry enabled."));
}