返回 CodeWhale
telemetry_notice.rs
根目录 / crates / tui / src / telemetry_notice.rs
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
313 lines RUST