diff --git a/changelog.d/9047-telemetry-master-opt-out.md b/changelog.d/9047-telemetry-master-opt-out.md new file mode 100644 index 0000000000..de3cbc2754 --- /dev/null +++ b/changelog.d/9047-telemetry-master-opt-out.md @@ -0,0 +1,10 @@ +### Fixed + +- Treat the first-run telemetry decision as the master consent gate. Declining + it, leaving it unanswered, setting `PERRY_NO_TELEMETRY=1`, or running in CI + now prevents generic usage events, beta-error reports, and compatibility + reports from being sent. + +- Fail closed for existing configs that have `telemetry.enabled = false` but + `compatibility_reports = "on"`, and re-check consent immediately before each + Chirp request so an opt-out made while an event is queued still wins. diff --git a/crates/perry/src/compat_reports.rs b/crates/perry/src/compat_reports.rs index 27a9fcbfd4..f0230a1863 100644 --- a/crates/perry/src/compat_reports.rs +++ b/crates/perry/src/compat_reports.rs @@ -105,15 +105,13 @@ impl ReportSink for QueueSink { } } -/// Read the active `compatibility_reports` mode for this process, -/// honouring env-level overrides (`PERRY_NO_TELEMETRY=1`, `CI=true`). +/// Read the active `compatibility_reports` mode for this process, honouring +/// the master consent and env-level overrides (`PERRY_NO_TELEMETRY=1`, +/// `CI=true`). pub(crate) fn active_mode() -> CompatibilityReports { - if telemetry::should_skip_telemetry() { - return CompatibilityReports::Off; - } - load_telemetry_config() - .map(|c| c.compatibility_reports) - .unwrap_or(CompatibilityReports::Ask) + telemetry::active_telemetry_config() + .map(|config| config.compatibility_reports) + .unwrap_or(CompatibilityReports::Off) } /// Install the diagnostic sink so HIR/codegen emission sites enqueue @@ -435,6 +433,10 @@ fn persist_mode(mode: CompatibilityReports) { /// POST a single compatibility report to Chirp. Best-effort; failures are /// silently swallowed (this is opt-in background telemetry). fn send_compat_report(report: &CompatibilityReport) { + if !telemetry::is_telemetry_enabled() { + return; + } + let body = match serde_json::to_value(report) { Ok(v) => v, Err(_) => return, @@ -442,6 +444,12 @@ fn send_compat_report(report: &CompatibilityReport) { let client_id = report.client_id.clone(); std::thread::spawn(move || { + // The master opt-out may have changed while this report was queued. + // Check again at the network boundary and fail closed. + if !telemetry::is_telemetry_enabled() { + return; + } + let client = match reqwest::blocking::Client::builder() .connect_timeout(std::time::Duration::from_secs(3)) .timeout(std::time::Duration::from_secs(5)) diff --git a/crates/perry/src/main.rs b/crates/perry/src/main.rs index d55ea7ca07..80ec75f1d8 100644 --- a/crates/perry/src/main.rs +++ b/crates/perry/src/main.rs @@ -439,9 +439,9 @@ fn main_inner() -> Result<()> { }; // #849: install the compat-report sink so diagnostic emission sites - // can enqueue reports. Honors `compatibility_reports = "off"` (skips - // installation entirely) and `PERRY_NO_TELEMETRY=1`/`CI=true` (same - // env overrides as the generic telemetry channel). + // can enqueue reports. The first-run `telemetry.enabled` consent is the + // master gate; `compatibility_reports = "off"` and the environment-level + // overrides can disable this channel further. compat_reports::install_sink(); // Resolve the update policy ONCE, here, and use it at both hook sites. diff --git a/crates/perry/src/telemetry.rs b/crates/perry/src/telemetry.rs index 61e6b3a878..b316bb0bd1 100644 --- a/crates/perry/src/telemetry.rs +++ b/crates/perry/src/telemetry.rs @@ -1,7 +1,8 @@ //! Anonymous usage statistics for Perry CLI //! //! Opt-in telemetry via Chirp API. On first interactive run, the user is asked -//! once if stats collection is OK (default: yes). All telemetry is fire-and-forget +//! once if stats collection is OK (default: yes). Declining that prompt is a +//! master opt-out for every telemetry channel. All telemetry is fire-and-forget //! on background threads — never slows down the CLI. use serde::{Deserialize, Serialize}; @@ -22,9 +23,9 @@ const CHIRP_KEY: &str = "testkey123"; const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); -/// Tri-state setting for the #849 opt-in compatibility-report channel. -/// Decoupled from `enabled` (generic usage analytics) so users can opt in -/// to one without the other. +/// Tri-state setting for the #849 compatibility-report channel. +/// `TelemetryConfig::enabled` is the master gate; this setting can further +/// restrict compatibility reports after telemetry has been enabled. #[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub(crate) enum CompatibilityReports { @@ -53,24 +54,21 @@ pub(crate) struct TelemetryConfig { pub(crate) enabled: bool, #[serde(default)] pub(crate) client_id: String, - /// #849: opt-in compatibility reports. Off by default in code (`Ask` - /// is the variant default but `#[serde(default)]` means existing - /// installs without the field get `Off` until they upgrade — see - /// `compatibility_reports_default`). + /// #849: compatibility reports. This setting is ignored unless the + /// master `enabled` consent is true. #[serde(default = "compatibility_reports_default")] pub(crate) compatibility_reports: CompatibilityReports, } -/// Existing users (config.toml predates #849) get `Ask` so they see the -/// prompt next time a gap is hit. New installs running through -/// `init_and_check_consent()` also land on `Ask`. Set explicitly to `Off` -/// to opt out at the file level. +/// Telemetry-enabled users whose config predates #849 get `Ask` so they see +/// the focused prompt next time a compatibility gap is hit. Master opt-outs +/// remain off regardless of this default. fn compatibility_reports_default() -> CompatibilityReports { CompatibilityReports::Ask } /// Returns true if telemetry should be skipped entirely (explicit opt-out). -pub(crate) fn should_skip_telemetry() -> bool { +fn should_skip_telemetry() -> bool { if std::env::var("PERRY_NO_TELEMETRY").is_ok_and(|v| v == "1" || v == "true") { return true; } @@ -80,6 +78,28 @@ pub(crate) fn should_skip_telemetry() -> bool { false } +fn apply_master_consent( + config: Option, + environment_opt_out: bool, +) -> Option { + if environment_opt_out { + return None; + } + config.filter(|config| config.enabled) +} + +/// Return the telemetry config only after the user has granted the master +/// consent and no environment-level override disables it. Every network +/// telemetry path must use this gate, including paths that do not go through +/// `main`'s `telemetry_active` flag. +pub(crate) fn active_telemetry_config() -> Option { + apply_master_consent(load_telemetry_config(), should_skip_telemetry()) +} + +pub(crate) fn is_telemetry_enabled() -> bool { + active_telemetry_config().is_some() +} + /// Returns true if we should skip the interactive consent prompt /// (non-TTY environments can't prompt, but should still send if already consented). fn should_skip_consent_prompt() -> bool { @@ -146,20 +166,26 @@ fn prompt_consent() -> bool { .interact() .unwrap_or(false); - let config = TelemetryConfig { - enabled: consent, - client_id: generate_client_id(), - // Generic analytics consent prompt doesn't speak for the - // separate #849 compat-report channel — leave it on `Ask` so - // the user gets a focused, in-context prompt the first time - // a gap actually fires. - compatibility_reports: CompatibilityReports::Ask, - }; - save_telemetry_config(&config); + save_telemetry_config(&config_for_consent(consent)); consent } +fn config_for_consent(consent: bool) -> TelemetryConfig { + TelemetryConfig { + enabled: consent, + client_id: generate_client_id(), + // A no at the first-run prompt means no telemetry of any kind. + // Opted-in users still get the focused, in-context prompt before + // the first compatibility report is sent. + compatibility_reports: if consent { + CompatibilityReports::Ask + } else { + CompatibilityReports::Off + }, + } +} + /// Check skip conditions, load config, prompt if needed. /// Returns true if telemetry is active for this session. pub(crate) fn init_and_check_consent() -> bool { @@ -178,9 +204,9 @@ pub(crate) fn init_and_check_consent() -> bool { /// Send an event on a background thread. The thread is tracked so `flush()` /// can wait for it before process exit. All errors are silently ignored. pub(crate) fn send_event(event: &str, dims: &[(&str, &str)]) { - let config = match load_telemetry_config() { - Some(c) if c.enabled => c, - _ => return, + let config = match active_telemetry_config() { + Some(config) => config, + None => return, }; let event = event.to_string(); @@ -228,6 +254,12 @@ pub(crate) fn flush() { /// Actual HTTP POST to Chirp API. /// Chirp expects `dims` object with known keys (platform, target, version, status, etc.). fn send_event_blocking(event: &str, dims: &[(String, String)], client_id: &str) { + // Re-check immediately before constructing the HTTP client. This keeps an + // opt-out made while a background event is queued from racing with send. + if !is_telemetry_enabled() { + return; + } + let client = match reqwest::blocking::Client::builder() .connect_timeout(CONNECT_TIMEOUT) .timeout(REQUEST_TIMEOUT) @@ -255,3 +287,50 @@ fn send_event_blocking(event: &str, dims: &[(String, String)], client_id: &str) .json(&body) .send(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn declined_master_consent_disables_compatibility_reports() { + let config = config_for_consent(false); + + assert!(!config.enabled); + assert_eq!(config.compatibility_reports, CompatibilityReports::Off); + } + + #[test] + fn master_opt_out_rejects_even_an_enabled_compatibility_channel() { + let config = TelemetryConfig { + enabled: false, + client_id: "anonymous-id".into(), + compatibility_reports: CompatibilityReports::On, + }; + + assert!(apply_master_consent(Some(config), false).is_none()); + assert!(apply_master_consent(None, false).is_none()); + } + + #[test] + fn environment_opt_out_overrides_stored_consent() { + let config = TelemetryConfig { + enabled: true, + client_id: "anonymous-id".into(), + compatibility_reports: CompatibilityReports::On, + }; + + let active = apply_master_consent(Some(config.clone()), false) + .expect("stored master consent should enable telemetry"); + assert_eq!(active.compatibility_reports, CompatibilityReports::On); + assert!(apply_master_consent(Some(config), true).is_none()); + } + + #[test] + fn accepted_master_consent_keeps_compatibility_reports_opt_in() { + let config = config_for_consent(true); + + assert!(config.enabled); + assert_eq!(config.compatibility_reports, CompatibilityReports::Ask); + } +} diff --git a/docs/src/cli/telemetry.md b/docs/src/cli/telemetry.md index eba96a66c9..c69ac0a497 100644 --- a/docs/src/cli/telemetry.md +++ b/docs/src/cli/telemetry.md @@ -1,10 +1,12 @@ # Privacy & Telemetry -Perry ships **two independent opt-in channels** for sending data home — -nothing leaves your machine without an explicit `enabled = true` or `on` in -`~/.perry/config.toml`. Both honour `PERRY_NO_TELEMETRY=1` and `CI=true`. +Perry sends no telemetry unless you accept the first-run prompt (or explicitly +set `telemetry.enabled = true` in `~/.perry/config.toml`). This setting is the +master consent gate for every telemetry channel. If it is false or missing, +nothing is sent, even when `compatibility_reports = "on"`. The environment +overrides `PERRY_NO_TELEMETRY=1` and `CI=true` always win as well. -## 1. Generic usage analytics — `telemetry.enabled` +## 1. Master consent and generic usage analytics — `telemetry.enabled` Counts `perry compile`, `perry init`, `perry publish` invocations on a background HTTP POST. Sends: command name, platform (`darwin`/`linux`/...), @@ -12,8 +14,9 @@ Perry version, success/error status, and an anonymous client UUID. ## 2. Compatibility reports — `telemetry.compatibility_reports` (#849) -Separate opt-in for "I hit an unsupported TS/Node feature and bailed." Sends -a structured report when the compiler emits one of these diagnostic codes: +Additional opt-in for "I hit an unsupported TS/Node feature and bailed." +This channel is available only while the master `enabled` consent is true. +It sends a structured report when the compiler emits one of these diagnostic codes: `UnsupportedBinaryOp`, `UnsupportedExpression`, `UnsupportedStatement`, `DynamicPropertyAccess`, `ImplicitCoercion`, `UnresolvedImport`, `NoOpStub`. @@ -64,8 +67,8 @@ To opt out at the file level, edit `~/.perry/config.toml`: ```toml [telemetry] -enabled = false # generic analytics off -compatibility_reports = "off" # #849 compat reports off +enabled = false # all telemetry off +compatibility_reports = "off" # optional; master opt-out already wins ``` See also the [`PERRY_NO_TELEMETRY` row in the perry.toml reference](perry-toml.md).