use std::io::IsTerminal;
use crate::cli::output::OutputConfig;
use crate::core::config::{set_telemetry, Config};
pub const fn baked_key() -> &'static str {
env!("SAFERSKILLS_POSTHOG_KEY")
}
#[cfg(feature = "telemetry-network")]
const POSTHOG_HOST: &str = env!("SAFERSKILLS_POSTHOG_HOST");
fn env_present(key: &str) -> bool {
std::env::var_os(key).is_some_and(|v| !v.is_empty())
}
fn env_bool(key: &str) -> Option<bool> {
std::env::var(key)
.ok()
.map(|v| matches!(v.as_str(), "true" | "1"))
}
fn opted_out_env() -> bool {
env_present("SAFERSKILLS_NO_TELEMETRY") || env_present("DO_NOT_TRACK") || env_present("CI")
}
pub fn is_enabled(config_allows: bool) -> bool {
config_allows && !opted_out_env() && !baked_key().is_empty()
}
fn duration_bucket(ms: u64) -> &'static str {
match ms {
0..=99 => "<100ms",
100..=499 => "100-500ms",
500..=1999 => "500ms-2s",
2000..=9999 => "2-10s",
_ => ">10s",
}
}
fn event_label(command: &str, subcommand: Option<&str>) -> String {
match subcommand {
Some(s) => format!("{command}:{s}"),
None => command.to_string(),
}
}
fn consent_from(opted_out: bool, env_explicit: Option<bool>, stored: Option<bool>) -> Option<bool> {
if opted_out {
return Some(false);
}
env_explicit.or(stored)
}
pub fn resolve_telemetry_consent(
output: &OutputConfig,
config: &Config,
non_interactive: bool,
) -> bool {
if let Some(v) = consent_from(
opted_out_env(),
env_bool("SAFERSKILLS_TELEMETRY"),
config.telemetry,
) {
return v;
}
let interactive = !non_interactive
&& !output.is_json()
&& !output.is_quiet()
&& std::io::stderr().is_terminal();
if !interactive || baked_key().is_empty() {
return false;
}
let choice = inquire::Confirm::new(
"Enable anonymous usage analytics to help improve the SaferSkills CLI?",
)
.with_default(false)
.with_help_message("Anonymous: which command ran, its exit code, and a coarse duration — never arguments, names, paths, or any PII. Change anytime in ~/.saferskills/config.toml or with SAFERSKILLS_NO_TELEMETRY=1.")
.prompt()
.unwrap_or(false);
let _ = set_telemetry(choice);
choice
}
pub fn install_reporting_allowed() -> bool {
!opted_out_env() && !baked_key().is_empty()
}
fn command_invoked_payload(
command: &str,
subcommand: Option<&str>,
exit_code: i32,
duration_ms: u64,
) -> serde_json::Value {
serde_json::json!({
"api_key": baked_key(),
"event": "command_invoked",
"properties": {
"distinct_id": "saferskills-cli",
"$process_person_profile": false,
"product": "saferskills",
"command": event_label(command, subcommand),
"exit_code": exit_code,
"duration_bucket": duration_bucket(duration_ms),
"cli_version": env!("CARGO_PKG_VERSION"),
}
})
}
pub async fn capture_command_invoked(
command: &str,
subcommand: Option<&str>,
exit_code: i32,
duration_ms: u64,
enabled: bool,
) {
if !enabled {
return;
}
send(command_invoked_payload(
command,
subcommand,
exit_code,
duration_ms,
))
.await;
}
#[cfg(feature = "telemetry-network")]
async fn send(payload: serde_json::Value) {
let Ok(client) = reqwest::Client::builder()
.use_rustls_tls()
.timeout(std::time::Duration::from_millis(1500))
.build()
else {
return;
};
let url = format!("{POSTHOG_HOST}/capture/");
let _ = client.post(url).json(&payload).send().await;
}
#[cfg(not(feature = "telemetry-network"))]
async fn send(payload: serde_json::Value) {
let _ = payload;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_short_circuits_regardless_of_env() {
assert!(!is_enabled(false));
}
#[test]
fn no_baked_key_means_disabled() {
if baked_key().is_empty() {
assert!(!is_enabled(true) || opted_out_env());
}
}
#[test]
fn consent_precedence() {
assert_eq!(consent_from(true, Some(true), Some(true)), Some(false));
assert_eq!(consent_from(false, Some(true), Some(false)), Some(true));
assert_eq!(consent_from(false, Some(false), Some(true)), Some(false));
assert_eq!(consent_from(false, None, Some(true)), Some(true));
assert_eq!(consent_from(false, None, Some(false)), Some(false));
assert_eq!(consent_from(false, None, None), None);
}
#[test]
fn duration_buckets() {
assert_eq!(duration_bucket(0), "<100ms");
assert_eq!(duration_bucket(250), "100-500ms");
assert_eq!(duration_bucket(1500), "500ms-2s");
assert_eq!(duration_bucket(5000), "2-10s");
assert_eq!(duration_bucket(60_000), ">10s");
}
#[test]
fn label_uses_grammar_not_values() {
assert_eq!(event_label("info", None), "info");
assert_eq!(event_label("update", Some("all")), "update:all");
}
#[test]
fn payload_tags_product_and_carries_no_pii() {
let p = command_invoked_payload("update", Some("all"), 0, 50);
assert_eq!(p["event"], "command_invoked");
assert_eq!(p["properties"]["product"], "saferskills");
assert_eq!(p["properties"]["command"], "update:all");
assert_eq!(p["properties"]["exit_code"], 0);
assert_eq!(p["properties"]["duration_bucket"], "<100ms");
assert_eq!(p["properties"]["$process_person_profile"], false);
}
}