| 1 | //! The first-run telemetry notice, on the interactive startup path. |
| 2 | //! |
| 3 | //! Shown once, before the terminal enters raw mode, on the same TTY the user |
| 4 | //! launched on. It is deliberately *not* hung off the setup wizard's deferral |
| 5 | //! machinery: `defer_update_checkpoint_for_app` persists a completed |
| 6 | //! constitution checkpoint without ever showing the user anything, and a |
| 7 | //! telemetry decision recorded that way would be a decision nobody made. |
| 8 | //! |
| 9 | //! Every path that does not render and answer the notice leaves |
| 10 | //! `telemetry_notice_decided_for` as `None`, and `None` means nothing is ever |
| 11 | //! collected. Silence is a supported outcome, not a degraded one: |
| 12 | //! |
| 13 | //! - `--skip-onboarding`: no notice, no decision, no emission. |
| 14 | //! - non-TTY (a pipe, CI, a container): no notice, no decision, no emission. |
| 15 | //! - answered "no": off, and not asked again until the notice content itself |
| 16 | //! changes. |
| 17 | |
| 18 | use std::io::{BufRead, IsTerminal, Write}; |
| 19 | |
| 20 | use codewhale_config::{SetupState, TELEMETRY_NOTICE_VERSION}; |
| 21 | use codewhale_telemetry::notice; |
| 22 | |
| 23 | /// Show the notice and record the answer, if and only if one is owed and this |
| 24 | /// process is on a terminal that can ask. |
| 25 | /// |
| 26 | /// Returns `true` when a decision was recorded. Never returns an error: a |
| 27 | /// notice that cannot be shown is a notice that was not answered, which is the |
| 28 | /// off state, which is the default. |
| 29 | pub fn prompt_if_due(skip_onboarding: bool, config_path: Option<std::path::PathBuf>) -> bool { |
| 30 | if skip_onboarding { |
| 31 | return false; |
| 32 | } |
| 33 | if !(std::io::stdin().is_terminal() && std::io::stderr().is_terminal()) { |
| 34 | return false; |
| 35 | } |
| 36 | // Read what the config file and the environment already say *before* |
| 37 | // asking. The notice used to consult neither, so it ran on a machine whose |
| 38 | // operator had declared `CODEWHALE_TELEMETRY=0` and whose config file |
| 39 | // already said `telemetry = false`, and a `y` rewrote that `false` to |
| 40 | // `true`. |
| 41 | let store = match codewhale_config::ConfigStore::load(config_path.clone()) { |
| 42 | Ok(store) => store, |
| 43 | Err(error) => { |
| 44 | // A config we cannot read is a config we must not write. No notice |
| 45 | // means no decision, which is the off state, which is the default. |
| 46 | tracing::debug!("telemetry notice skipped; config unreadable: {error}"); |
| 47 | return false; |
| 48 | } |
| 49 | }; |
| 50 | let resolved = store |
| 51 | .config |
| 52 | .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default()); |
| 53 | |
| 54 | let mut state = match SetupState::load() { |
| 55 | Ok(Some(state)) => state, |
| 56 | // A missing record is a first run, which is exactly when the notice is |
| 57 | // owed. An *unreadable* record is not: overwriting it would be the one |
| 58 | // failure mode that costs a user their constitution checkpoint. |
| 59 | Ok(None) => SetupState::default(), |
| 60 | Err(error) => { |
| 61 | tracing::debug!("telemetry notice skipped; setup state unreadable: {error}"); |
| 62 | return false; |
| 63 | } |
| 64 | }; |
| 65 | let gate = NoticeGate { |
| 66 | needs_notice: state.needs_telemetry_notice(TELEMETRY_NOTICE_VERSION), |
| 67 | persisted_off: resolved.telemetry_explicit_off, |
| 68 | floor_in_force: codewhale_config::telemetry_floor_in_force(), |
| 69 | }; |
| 70 | if !gate.may_ask() { |
| 71 | return false; |
| 72 | } |
| 73 | |
| 74 | let opt_in = ask(&mut std::io::stderr(), &mut std::io::stdin().lock()); |
| 75 | |
| 76 | // Enabling writes *both* halves. They are independent AND conditions at |
| 77 | // emit time, so neither alone does anything, and that is what makes a |
| 78 | // stale pre-existing `telemetry = true` — a key that has been settable and |
| 79 | // inert for a long time — stay inert. |
| 80 | // |
| 81 | // Config first, decision second. Either order fails closed: a config write |
| 82 | // without a decision is `ForcedOff` for want of consent, and a decision |
| 83 | // without the config value is `ForcedOff` for want of the switch. |
| 84 | if opt_in && let Err(error) = write_config_opt_in(config_path) { |
| 85 | tracing::warn!("telemetry opt-in was not saved to config: {error}"); |
| 86 | let _ = writeln!( |
| 87 | std::io::stderr(), |
| 88 | " Could not save that setting; telemetry stays off.\n" |
| 89 | ); |
| 90 | return false; |
| 91 | } |
| 92 | |
| 93 | state.record_telemetry_notice(TELEMETRY_NOTICE_VERSION, opt_in); |
| 94 | if let Err(error) = state.save() { |
| 95 | // Nothing was recorded, so the notice is still owed and will be asked |
| 96 | // again. Emitting on the strength of an answer we failed to store |
| 97 | // would be collection without a record of consent. |
| 98 | tracing::warn!("telemetry decision was not saved: {error}"); |
| 99 | return false; |
| 100 | } |
| 101 | let _ = writeln!(std::io::stderr(), "{}\n", notice::decision_receipt(opt_in)); |
| 102 | opt_in |
| 103 | } |
| 104 | |
| 105 | /// Everything that decides whether the question may be *put*, as opposed to how |
| 106 | /// it is answered. |
| 107 | /// |
| 108 | /// Being asked is not collection, but it is not free either: the answer is |
| 109 | /// written to two durable registers, one of which may already hold the |
| 110 | /// opposite. A question whose "yes" would reverse a decision somebody already |
| 111 | /// made, or would be overridden by this environment anyway, is a question with |
| 112 | /// no honest answer — so it is not asked. |
| 113 | struct NoticeGate { |
| 114 | /// No decision recorded for the current notice version. |
| 115 | needs_notice: bool, |
| 116 | /// `telemetry = false` is in the config file. This is the persistent |
| 117 | /// opt-out the notice itself advertises; asking again and writing `true` |
| 118 | /// over it is exactly the reversal the notice promises not to perform. |
| 119 | persisted_off: bool, |
| 120 | /// An environment-level kill switch is in force. The operator has already |
| 121 | /// answered for this machine, and a `y` here could not take effect on this |
| 122 | /// run — but it would take effect on every later run that does not inherit |
| 123 | /// the variable. |
| 124 | floor_in_force: bool, |
| 125 | } |
| 126 | |
| 127 | impl NoticeGate { |
| 128 | fn may_ask(&self) -> bool { |
| 129 | self.needs_notice && !self.persisted_off && !self.floor_in_force |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | /// Set `telemetry = true` in the same config file this process was launched |
| 134 | /// with. |
| 135 | /// |
| 136 | /// Re-reads the file rather than reusing the copy the gate was computed from: |
| 137 | /// this is the only write in the feature that can turn collection *on*, so it |
| 138 | /// re-establishes the invariant against the bytes on disk at the moment of the |
| 139 | /// write, not against a snapshot taken before the user was even asked. |
| 140 | fn write_config_opt_in(config_path: Option<std::path::PathBuf>) -> anyhow::Result<()> { |
| 141 | let mut store = codewhale_config::ConfigStore::load(config_path)?; |
| 142 | let resolved = store |
| 143 | .config |
| 144 | .resolve_runtime_options(&codewhale_config::CliRuntimeOverrides::default()); |
| 145 | anyhow::ensure!( |
| 146 | !resolved.telemetry_explicit_off, |
| 147 | "the config file says telemetry = false; the first-run notice never reverses a persistent opt-out" |
| 148 | ); |
| 149 | store.config.set_value("telemetry", "true")?; |
| 150 | store.save() |
| 151 | } |
| 152 | |
| 153 | /// Render the notice to `out` and read one answer from `input`. |
| 154 | /// |
| 155 | /// Split out so the wording, the default, and the parsing are testable without |
| 156 | /// a terminal. Enter — an empty line — declines, and so does EOF. |
| 157 | fn ask(out: &mut impl Write, input: &mut impl BufRead) -> bool { |
| 158 | let _ = writeln!( |
| 159 | out, |
| 160 | "\n {}\n\n{}\n\n [ Enable ] [ No thanks ]\n\n Selected: No thanks — press Enter to keep telemetry off.\n", |
| 161 | notice::NOTICE_HEADLINE, |
| 162 | indent(notice::NOTICE_BODY), |
| 163 | ); |
| 164 | let _ = write!(out, " {} ", notice::NOTICE_PROMPT); |
| 165 | let _ = out.flush(); |
| 166 | |
| 167 | let mut answer = String::new(); |
| 168 | if input.read_line(&mut answer).is_err() { |
| 169 | return false; |
| 170 | } |
| 171 | notice::answer_is_yes(&answer) |
| 172 | } |
| 173 | |
| 174 | fn indent(body: &str) -> String { |
| 175 | body.lines() |
| 176 | .map(|line| { |
| 177 | if line.is_empty() { |
| 178 | String::new() |
| 179 | } else { |
| 180 | format!(" {line}") |
| 181 | } |
| 182 | }) |
| 183 | .collect::<Vec<_>>() |
| 184 | .join("\n") |
| 185 | } |
| 186 | |
| 187 | #[cfg(test)] |
| 188 | mod tests { |
| 189 | use super::*; |
| 190 | |
| 191 | fn ask_with(answer: &str) -> (bool, String) { |
| 192 | let mut out: Vec<u8> = Vec::new(); |
| 193 | let mut input = answer.as_bytes(); |
| 194 | let decision = ask(&mut out, &mut input); |
| 195 | (decision, String::from_utf8(out).expect("utf8")) |
| 196 | } |
| 197 | |
| 198 | #[test] |
| 199 | fn enter_declines() { |
| 200 | // The declining option is pre-selected and Enter takes it. Enabling |
| 201 | // costs a deliberate keystroke; declining costs none. |
| 202 | assert!(!ask_with("\n").0); |
| 203 | assert!(!ask_with("").0); |
| 204 | assert!(!ask_with(" \n").0); |
| 205 | } |
| 206 | |
| 207 | #[test] |
| 208 | fn only_an_affirmative_answer_enables() { |
| 209 | assert!(ask_with("y\n").0); |
| 210 | assert!(ask_with("Y\n").0); |
| 211 | assert!(ask_with("yes\n").0); |
| 212 | assert!(!ask_with("n\n").0); |
| 213 | assert!(!ask_with("no\n").0); |
| 214 | assert!(!ask_with("sure\n").0); |
| 215 | assert!(!ask_with("1\n").0); |
| 216 | } |
| 217 | |
| 218 | #[test] |
| 219 | fn the_notice_states_the_red_lines_and_the_way_out() { |
| 220 | let (_, rendered) = ask_with("\n"); |
| 221 | for claim in [ |
| 222 | "never sends prompts", |
| 223 | "Not sampled, not hashed", |
| 224 | "random ID stored on this machine", |
| 225 | "every 90 days", |
| 226 | "docs/TELEMETRY.md", |
| 227 | "codewhale config set telemetry false", |
| 228 | "CODEWHALE_TELEMETRY=0", |
| 229 | "press Enter to keep telemetry off", |
| 230 | ] { |
| 231 | assert!(rendered.contains(claim), "notice is missing: {claim}"); |
| 232 | } |
| 233 | assert!( |
| 234 | !rendered.contains("anonymized"), |
| 235 | "the notice must not imply anonymization it does not perform" |
| 236 | ); |
| 237 | } |
| 238 | |
| 239 | fn gate(needs_notice: bool, persisted_off: bool, floor_in_force: bool) -> NoticeGate { |
| 240 | NoticeGate { |
| 241 | needs_notice, |
| 242 | persisted_off, |
| 243 | floor_in_force, |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | #[test] |
| 248 | fn the_notice_is_not_put_to_someone_who_has_already_answered_it_durably() { |
| 249 | // Regression: the gate consulted only the setup-state record, so on a |
| 250 | // machine with `CODEWHALE_TELEMETRY=0` exported and `telemetry = false` |
| 251 | // in the config file the notice rendered anyway — and `y` rewrote that |
| 252 | // `false` to `true`, reversing a persistent opt-out with no warning. |
| 253 | assert!(gate(true, false, false).may_ask(), "an ordinary first run"); |
| 254 | assert!( |
| 255 | !gate(true, true, false).may_ask(), |
| 256 | "a persisted `telemetry = false` is an answer; do not ask again" |
| 257 | ); |
| 258 | assert!( |
| 259 | !gate(true, false, true).may_ask(), |
| 260 | "an environment kill switch is an answer for this machine" |
| 261 | ); |
| 262 | assert!(!gate(true, true, true).may_ask()); |
| 263 | // And the original condition still governs: an answered notice is not |
| 264 | // re-asked for any reason. |
| 265 | for persisted_off in [false, true] { |
| 266 | for floor_in_force in [false, true] { |
| 267 | assert!(!gate(false, persisted_off, floor_in_force).may_ask()); |
| 268 | } |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | #[test] |
| 273 | fn opting_in_never_reverses_a_persisted_opt_out() { |
| 274 | // Belt and braces behind the gate: this is the one write in the whole |
| 275 | // feature that can turn collection on, so it re-establishes the |
| 276 | // invariant against the bytes on disk at the moment of the write. |
| 277 | let dir = tempfile::tempdir().expect("tempdir"); |
| 278 | let path = dir.path().join("config.toml"); |
| 279 | std::fs::write(&path, "telemetry = false\n").expect("seed config"); |
| 280 | |
| 281 | let error = write_config_opt_in(Some(path.clone())) |
| 282 | .expect_err("a persisted opt-out must not be overwritten"); |
| 283 | assert!( |
| 284 | error.to_string().contains("never reverses"), |
| 285 | "unexpected error: {error}" |
| 286 | ); |
| 287 | let after = std::fs::read_to_string(&path).expect("read back"); |
| 288 | assert!( |
| 289 | after.contains("telemetry = false"), |
| 290 | "the file was rewritten: {after}" |
| 291 | ); |
| 292 | |
| 293 | // A file that has never said anything is writable, which is the |
| 294 | // ordinary opt-in path. |
| 295 | let fresh = dir.path().join("fresh.toml"); |
| 296 | std::fs::write(&fresh, "").expect("seed fresh config"); |
| 297 | write_config_opt_in(Some(fresh.clone())).expect("a fresh config accepts the opt-in"); |
| 298 | assert!( |
| 299 | std::fs::read_to_string(&fresh) |
| 300 | .expect("read back") |
| 301 | .contains("telemetry = true") |
| 302 | ); |
| 303 | } |
| 304 | |
| 305 | #[test] |
| 306 | fn skip_onboarding_records_no_decision() { |
| 307 | // Not a decision, and not a deferral that pretends to be one. The |
| 308 | // constitution checkpoint records `Deferred` on this path; telemetry |
| 309 | // deliberately does not mirror it. |
| 310 | assert!(!prompt_if_due(true, None)); |
| 311 | } |
| 312 | } |
| 313 |