cerulion_cli 2.0.0

Cerulion command-line interface: create workspaces, build nodes, run graphs, and inspect topics
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
// SPDX-License-Identifier: AGPL-3.0-only
//! Usage telemetry for the `cerulion` CLI: the `cerulion telemetry` verb, the
//! one-time first-run notice and the `cli_command_run` event. The public
//! disclosure is `docs/telemetry.md`.
//!
//! Nothing is sent unless [`Client::from_env_or_key`] returns a client, which
//! needs a key (`POSTHOG_API_KEY`, else the `CERULION_POSTHOG_KEY` a release
//! build was compiled with) and consent. The run that prints the notice
//! sends nothing, and so does a run under the network kill switch. The
//! notice prints only on a terminal: a run without one
//! sends as usual and leaves the notice for the first run on a terminal.

use std::io::{IsTerminal, Write};
use std::process::ExitCode;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, PoisonError};
use std::time::{Duration, Instant};

use cerulion_cli_engine::auth;
use cerulion_cli_engine::error::{CliError, CliResult};
use cerulion_cli_engine::login_cmd::LoginOutcome;
use cerulion_telemetry::consent::{self, Source};
use cerulion_telemetry::{guard, Client, Common, EventSpec, Props, DEFAULT_SHUTDOWN_BUDGET};

use crate::cli::TelemetryAction;

/// One CLI invocation: which verb, how it ended, roughly how long it took.
pub const CLI_COMMAND_RUN: EventSpec = EventSpec {
    name: "cli_command_run",
    allowlist: &[
        "verb",
        "subverb",
        "exit_code",
        "duration_bucket",
        "install_method",
    ],
};

/// A completed device login. The account is the event's `distinct_id`.
pub const CLI_LOGIN_COMPLETED: EventSpec = EventSpec {
    name: "cli_login_completed",
    allowlist: &["is_account_switch"],
};

/// The PostHog project key a release build carries; `None` in a source build.
/// Read by the compiler, so the key never passes through build-script output.
const BAKED_KEY: Option<&str> = option_env!("CERULION_POSTHOG_KEY");

/// Whether this process may send: set once [`CommandRun::start`] has
/// decided to record, so a login inside the run that printed the notice, or
/// inside an unrecorded verb, sends nothing either.
static SENDING: AtomicBool = AtomicBool::new(false);

/// Set when [`CommandRun::start`] could have sent but printed the notice
/// instead: a first login inside this run leaves its anonymous id pending.
static NOTICE_RUN: AtomicBool = AtomicBool::new(false);

/// Set when [`CommandRun::start`] could have sent but the network kill switch
/// is engaged: a first login inside this run leaves its anonymous id pending,
/// as the notice run does.
static SWITCH_RUN: AtomicBool = AtomicBool::new(false);

/// Set when [`login_anon_id`] found an unmerged anonymous id it could not
/// carry because this run sends nothing.
static UNCARRIED: AtomicBool = AtomicBool::new(false);

/// The process's one client, installed by [`CommandRun::start`] and shut down
/// once by [`CommandRun::finish`], so every event of an invocation shares
/// one [`DEFAULT_SHUTDOWN_BUDGET`].
static CLIENT: Mutex<Option<Client>> = Mutex::new(None);

/// Whether this process runs under CI, read once so the `ci` tag and every
/// decision made from it agree.
static CI_RUN: std::sync::OnceLock<bool> = std::sync::OnceLock::new();

/// See [`CI_RUN`]. A CI run prints no notice, sends every event under the
/// random id with no person profile, and never joins that id to an account.
fn ci_run() -> bool {
    *CI_RUN.get_or_init(cerulion_telemetry::ci::detected)
}

/// Next to the consent file: an anonymous id that a signed-in account has
/// not been merged with yet. Holds no data; its presence is the flag. The
/// merge joins to the account the events sent under the same consent file's
/// id before the login: by CLI runs without a terminal, and by another
/// surface on the machine such as the vizd daemon. Those events are
/// personless (`$process_person_profile: false`); PostHog ingests them under a
/// person id derived from the distinct id, and the `$create_alias` that names
/// the id links them to the account's person through a person override.
/// PostHog's handbook describes that override for `$identify`
/// (<https://posthog.com/handbook/engineering/person-processing>), the
/// ingestion pipeline handles `$create_alias` almost the same way
/// (<https://posthog.com/docs/how-posthog-works/ingestion-pipeline>), and an
/// alias merges past and future events into one person
/// (<https://posthog.com/docs/product-analytics/identify>). CI runs send under
/// their own per-process id ([`consent::event_anon_id`]), so this never joins
/// them.
#[cfg(feature = "telemetry")]
const PENDING_ALIAS_FILE: &str = "telemetry_alias_pending";
/// Beside `telemetry.json`: the account the anonymous id was last used for.
/// A signed-in account that differs from it has the anonymous id rotated
/// before anything more is sent, so one id is never attributed to two
/// accounts, even when a login saved the new account and then failed. An
/// empty record is a claim: a login carried the id and no account is
/// recorded for it, because that login is still running, failed, or could
/// not write the account; or a rotation the id was owed failed. Either way
/// no later login carries it, and every run with an account rotates it
/// first.
#[cfg(feature = "telemetry")]
const ANON_ACCOUNT_FILE: &str = "telemetry_anon_account";

/// Printed to stderr once per machine, on the first run that could send and
/// whose stderr is a terminal. One line, so it stays out of the way.
pub const NOTICE: &str = "Cerulion sends usage telemetry; turn it off with \
`cerulion telemetry off` (details: https://cerulion.com/telemetry).";

/// The page that lists what is sent, in full: a short address that redirects
/// to `docs/telemetry.md`. `cerulion telemetry status` prints it and
/// [`NOTICE`] carries it.
const DETAILS_URL: &str = "https://cerulion.com/telemetry";

/// Verbs that record no `cli_command_run`: the consent verb itself (an
/// opt-out must not be counted), the completion hook a shell runs on every
/// start, and the recorder daemon, which runs as its own long-lived process.
const UNRECORDED_VERBS: &[&str] = &["telemetry", "completions", "bagd"];

/// Subprocesses a recorded parent spawns; the parent's event covers them.
const UNRECORDED_SUBVERBS: &[&str] = &["run-worker", "run-gateway"];

/// The fixed properties every CLI event carries.
pub fn common() -> Common {
    Common {
        surface: "cli".into(),
        env: if cfg!(debug_assertions) {
            "dev"
        } else {
            "prod"
        }
        .into(),
        app_version: env!("CARGO_PKG_VERSION").into(),
        channel: None,
        ci: ci_run(),
    }
}

/// A coarse duration: enough to tell a quick query from a long run, never a
/// timing precise enough to fingerprint anything.
pub fn duration_bucket(elapsed: Duration) -> &'static str {
    match elapsed.as_secs() {
        0 => "lt_1s",
        1..=9 => "1s_10s",
        10..=59 => "10s_1m",
        60..=599 => "1m_10m",
        _ => "gte_10m",
    }
}

/// The numeric process exit code, or `-1` if it is outside `0..=255`.
pub fn exit_code_number(code: ExitCode) -> i64 {
    (0..=u8::MAX)
        .find(|n| ExitCode::from(*n) == code)
        .map_or(-1, i64::from)
}

/// `cli_command_run` properties. `verb` and `subverb` are clap subcommand
/// names, fixed strings from the command definition, never user input.
pub fn command_run_props(
    verb: &str,
    subverb: Option<&str>,
    code: ExitCode,
    elapsed: Duration,
    install_method: Option<&str>,
) -> Props {
    let mut props: Props = vec![("verb".into(), verb.into())];
    if let Some(subverb) = subverb {
        props.push(("subverb".into(), subverb.into()));
    }
    props.push(("exit_code".into(), exit_code_number(code).into()));
    props.push(("duration_bucket".into(), duration_bucket(elapsed).into()));
    if let Some(method) = install_method {
        props.push(("install_method".into(), method.into()));
    }
    props
}

/// Whether an invocation of `verb subverb` records a `cli_command_run`.
pub fn is_recorded(verb: &str, subverb: Option<&str>) -> bool {
    !UNRECORDED_VERBS.contains(&verb) && !subverb.is_some_and(|s| UNRECORDED_SUBVERBS.contains(&s))
}

/// A started invocation, finished by [`CommandRun::finish`].
pub struct CommandRun {
    verb: String,
    subverb: Option<String>,
    started: Instant,
}

impl CommandRun {
    /// Begin recording, or `None` when nothing may be sent: no key or no
    /// consent, the network kill switch engaged for this invocation
    /// ([`crate::network_switch::engaged`]), an unrecorded verb, or this run
    /// printed the first-run notice. A run under the switch prints no notice
    /// either: it leaves the notice to the first run that can send, and the
    /// merge of a first login inside it to the next run that sends.
    pub fn start(
        matches: &clap::ArgMatches,
        switch: &crate::network_switch::SwitchEnv,
    ) -> Option<CommandRun> {
        let (verb, sub) = matches.subcommand()?;
        let subverb = sub.subcommand_name();
        if !is_recorded(verb, subverb) {
            return None;
        }
        if crate::network_switch::engaged(matches, switch) {
            // No client is built: nothing is sent, and no id leaves in a
            // login. The merge waits for the next run that sends.
            #[cfg(feature = "telemetry")]
            if key_present() && consent::status().enabled {
                SWITCH_RUN.store(true, Ordering::Relaxed);
            }
            return None;
        }
        let client = Client::from_env_or_key(BAKED_KEY, common())?;
        // The notice is printed by the first run whose stderr is a terminal,
        // and that run sends nothing. A CI run prints nothing and sends
        // (tagged `ci`), terminal or not; any other run without a terminal
        // prints nothing and sends as usual. Either leaves the notice to the
        // first run on a terminal. A closed stderr must not stop the command:
        // the notice is then never saved as shown, so a later run shows it.
        match consent::notice_shown() {
            Ok(true) => {}
            Ok(false) | Err(cerulion_telemetry::Error::Json(_)) if ci_run() => {}
            // Written outside the consent lock, so a stderr nobody drains
            // holds up only this run. Two first runs at once may both show
            // it; neither sends. It is saved as shown only once written.
            Ok(false) | Err(cerulion_telemetry::Error::Json(_))
                if std::io::stderr().is_terminal() =>
            {
                if writeln!(std::io::stderr(), "{NOTICE}").is_ok() {
                    let _ = consent::mark_notice_shown();
                }
                NOTICE_RUN.store(true, Ordering::Relaxed);
                return None;
            }
            Ok(false) | Err(cerulion_telemetry::Error::Json(_)) => {}
            // Without a readable consent file the notice cannot be tracked,
            // so it cannot be known to have been shown: send nothing.
            Err(_) => return None,
        }
        if !settle_anon_account() {
            return None;
        }
        merge_pending_alias(&client);
        *CLIENT.lock().unwrap_or_else(PoisonError::into_inner) = Some(client);
        SENDING.store(true, Ordering::Relaxed);
        Some(CommandRun {
            verb: verb.to_owned(),
            subverb: subverb.map(str::to_owned),
            started: Instant::now(),
        })
    }

    /// Record the outcome and flush within [`DEFAULT_SHUTDOWN_BUDGET`]. The
    /// identity is read here, after the command ran, so a first `login`
    /// is attributed to the account it just signed in.
    pub fn finish(self, code: ExitCode) {
        let props = command_run_props(
            &self.verb,
            self.subverb.as_deref(),
            code,
            self.started.elapsed(),
            crate::install_marker::current_method(),
        );
        emit(CLI_COMMAND_RUN, props);
        SENDING.store(false, Ordering::Relaxed);
        let client = CLIENT.lock().unwrap_or_else(PoisonError::into_inner).take();
        if let Some(mut client) = client {
            client.shutdown(DEFAULT_SHUTDOWN_BUDGET);
        }
    }
}

/// Queue one event on this invocation's client, attributed to the signed-in
/// account or else the anonymous id. A no-op when this process sends
/// nothing. Consent is read again at every event, so a `cerulion telemetry
/// off` from another terminal while a long command runs stops its events.
/// So is the account: a login in this run, or in another terminal, may have
/// saved another account, and the anonymous id is settled for it before the
/// event picks its identity. An id that cannot be settled stops this run's
/// events rather than send them under the previous account's id.
pub fn emit(spec: EventSpec, props: Props) {
    if !SENDING.load(Ordering::Relaxed) || !consent::status().enabled {
        return;
    }
    if !settle_anon_account() {
        SENDING.store(false, Ordering::Relaxed);
        return;
    }
    let guard = CLIENT.lock().unwrap_or_else(PoisonError::into_inner);
    let Some(client) = guard.as_ref() else {
        return;
    };
    let account = if ci_run() {
        None
    } else {
        auth::load().state().and_then(|s| hosted_sub(&s.account_id))
    };
    match account {
        Some(sub) => {
            consent::while_enabled(|| client.capture(spec, &sub, props));
        }
        None => {
            if let Ok(Some(anon_id)) = consent::event_anon_id() {
                consent::while_enabled(|| client.capture_anonymous(spec, &anon_id, props));
            }
        }
    }
}

#[cfg(feature = "telemetry")]
fn pending_alias_path() -> Option<std::path::PathBuf> {
    Some(
        consent::file_path()
            .ok()?
            .with_file_name(PENDING_ALIAS_FILE),
    )
}

#[cfg(feature = "telemetry")]
fn anon_account_path() -> Option<std::path::PathBuf> {
    Some(consent::file_path().ok()?.with_file_name(ANON_ACCOUNT_FILE))
}

/// What `telemetry_anon_account` says about the anonymous id.
#[cfg(feature = "telemetry")]
enum AnonAccount {
    /// No record: no account has used the id.
    Unclaimed,
    /// The account the id was last used for.
    Bound(String),
    /// A record exists but cannot be read, or there is no home to read it
    /// from: the id's account is unknown, so it is treated as another
    /// account's, never carried into a login and rotated before use.
    Unknown,
}

#[cfg(feature = "telemetry")]
fn anon_account() -> AnonAccount {
    let Some(path) = anon_account_path() else {
        return AnonAccount::Unknown;
    };
    match std::fs::read_to_string(path) {
        Ok(account) => AnonAccount::Bound(account),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => AnonAccount::Unclaimed,
        Err(_) => AnonAccount::Unknown,
    }
}

/// Record `account` as the anonymous id's account; `false` when the record
/// could not be written. The old record then stays, so a later run rotates
/// the id again rather than reuse it, and the caller must not send under an
/// id whose account is not on disk.
#[cfg(feature = "telemetry")]
fn bind_anon_account(account: &str) -> bool {
    let Some(path) = anon_account_path() else {
        return false;
    };
    match std::fs::write(path, account) {
        Ok(()) => true,
        Err(e) => {
            tracing::warn!(error = %e, "telemetry anonymous id account record not written");
            false
        }
    }
}

/// Claim the anonymous id for the login about to carry it: create its
/// account record, empty, before the id leaves the machine. The creation is
/// exclusive, so of concurrent first logins exactly one carries the id, and
/// a later login never finds the id unclaimed, even once `auth.json` is
/// gone and the account was never written over the claim. The claim is
/// never released: the login asks for the id right before its device-start
/// request, and a login that fails after that request may still have been
/// joined to its account by the account service, so the id is never carried
/// again and the next run with an account rotates it.
/// `false` when another login holds the claim or it cannot be made: the id
/// then stays on the machine.
#[cfg(feature = "telemetry")]
fn claim_anon_id() -> bool {
    let Some(path) = anon_account_path() else {
        return false;
    };
    match std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(path)
    {
        Ok(_) => true,
        Err(e) => {
            if e.kind() != std::io::ErrorKind::AlreadyExists {
                tracing::warn!(error = %e, "telemetry anonymous id not claimed for the login");
            }
            false
        }
    }
}

/// Remove the pending-alias marker; a marker that is already gone is fine.
#[cfg(feature = "telemetry")]
fn remove_pending_alias(path: &std::path::Path) {
    if let Err(e) = std::fs::remove_file(path) {
        if e.kind() != std::io::ErrorKind::NotFound {
            tracing::warn!(error = %e, "telemetry alias marker not removed");
        }
    }
}

/// Replace the anonymous id, dropping any merge left pending for the old
/// one. `true` when there was no consent file, so no id, to replace.
#[cfg(feature = "telemetry")]
fn rotate_existing_anon_id() -> bool {
    if !consent::file_path().is_ok_and(|p| p.exists()) {
        return true;
    }
    if let Some(path) = pending_alias_path() {
        remove_pending_alias(&path);
    }
    consent::rotate_anon_id().is_ok()
}

/// Rotate the anonymous id when the signed-in account is not the one it was
/// last used for; with no record yet, the signed-in account claims it.
/// `false` while that rotation, or the record of the account, fails: the run
/// must then send nothing, or the old account's id would be attributed to
/// the new one, or an id sent under this account could later be carried into
/// another.
fn settle_anon_account() -> bool {
    #[cfg(feature = "telemetry")]
    {
        let Some(account) = auth::load().state().map(|s| s.account_id.clone()) else {
            return true;
        };
        match anon_account() {
            AnonAccount::Bound(bound) if bound == account => return true,
            AnonAccount::Unclaimed => {}
            AnonAccount::Bound(_) | AnonAccount::Unknown => {
                if !rotate_existing_anon_id() {
                    return false;
                }
            }
        }
        bind_anon_account(&account)
    }
    #[cfg(not(feature = "telemetry"))]
    true
}

/// Merge an anonymous id left pending by a first login in a run that sent
/// nothing (the notice run, or a run under the network kill switch), now that
/// this run may send. The marker is removed
/// once read, unless the anonymous id cannot be read: without a hosted
/// account there is nothing to merge.
fn merge_pending_alias(client: &Client) {
    #[cfg(feature = "telemetry")]
    {
        // A CI run joins nothing to an account; the marker waits for a run
        // outside CI.
        if ci_run() {
            return;
        }
        let Some(path) = pending_alias_path().filter(|p| p.exists()) else {
            return;
        };
        let sub = auth::load().state().and_then(|s| hosted_sub(&s.account_id));
        let Ok(anon_id) = consent::anon_id() else {
            return;
        };
        // Settled under the consent lock: of concurrent runs, only the first
        // to take it still finds the marker, and an opt-out keeps the marker
        // so nothing owed is forgotten. A crash before the removal leaves
        // the marker for the next run.
        consent::while_enabled(|| {
            if !path.exists() {
                return;
            }
            if let (Some(sub), Some(anon_id)) = (sub, anon_id) {
                client.alias(&sub, &anon_id);
            }
            remove_pending_alias(&path);
        });
    }
    #[cfg(not(feature = "telemetry"))]
    let _ = client;
}

/// The signed-in account id, if it is a hosted account id (a lowercase UUID,
/// the account service's user id). Any other shape, such as the base64url id
/// a self-hosted account service issues, is never sent as a person id: the
/// event falls back to the anonymous id.
fn hosted_sub(account_id: &str) -> Option<String> {
    guard::check_sub(account_id)
        .ok()
        .map(|()| account_id.to_owned())
}

/// The anonymous id to carry into a device login, so the account service can
/// merge this machine's anonymous events into the account that signs in.
/// `None` when this process sends nothing, and when the id was already used
/// for an account: `auth.json` records a completed or a signed-out login, or
/// `telemetry_anon_account` names the account the id was merged into after
/// `auth.json` was removed. The id has then been merged into THAT account,
/// and carrying it into a login as someone else would merge the two people.
/// The id is claimed (see [`claim_anon_id`]) before it is returned, so this
/// is asked right before the device-start request, and only when a login
/// runs.
pub fn login_anon_id() -> Option<String> {
    // A CI run never joins the random id to an account.
    if ci_run() {
        return None;
    }
    // Only a machine that never signed in carries its id. A signed-out
    // record still names the account that was here, and a corrupt one may:
    // the next sign-in can be someone else, so neither counts as never.
    match auth::load() {
        auth::LoadedAuth::Absent => {}
        auth::LoadedAuth::Present(state) if !state.logged_in_ever => {}
        auth::LoadedAuth::Present(_)
        | auth::LoadedAuth::SignedOut { .. }
        | auth::LoadedAuth::Corrupt(_) => return None,
    }
    #[cfg(feature = "telemetry")]
    {
        if !matches!(anon_account(), AnonAccount::Unclaimed) {
            return None;
        }
    }
    if SENDING.load(Ordering::Relaxed) {
        // Consent is read again: `cerulion telemetry off` in another terminal
        // since this run started must keep the id out of the login.
        if !consent::status().enabled {
            return None;
        }
        // The file is user-editable: only a well-formed id leaves the machine.
        let anon_id = consent::anon_id()
            .ok()
            .flatten()
            .filter(|id| guard::check_anon_id(id).is_ok())?;
        #[cfg(feature = "telemetry")]
        {
            if !claim_anon_id() {
                return None;
            }
        }
        return Some(anon_id);
    }
    // The notice run and a run under the switch defer a merge; any other
    // non-sending run mints no id and writes no consent file.
    if (NOTICE_RUN.load(Ordering::Relaxed) || SWITCH_RUN.load(Ordering::Relaxed))
        && consent::status().enabled
        && consent::anon_id().ok().flatten().is_some()
    {
        UNCARRIED.store(true, Ordering::Relaxed);
    }
    None
}

/// After a successful login: on an account switch, or when the anonymous id
/// was last used for another account, replace it so it is never attributed
/// to the previous account again, and record the new account as its
/// account; then, when this process sends, record `cli_login_completed`.
/// The id the device-start body carried (`outcome.telemetry_anon_id`, put
/// there by [`login_anon_id`]) is also merged into the account from here,
/// which covers an account service that ignores the field; a service that
/// already merged it makes this a repeat of the same merge.
pub fn login_completed(outcome: &LoginOutcome) {
    #[cfg(feature = "telemetry")]
    let carried = settle_login(outcome);
    #[cfg(not(feature = "telemetry"))]
    let carried = outcome.telemetry_anon_id.as_deref();
    if !SENDING.load(Ordering::Relaxed) || !consent::status().enabled {
        return;
    }
    if ci_run() {
        // No merge from CI: the event goes out under the random id.
        emit(CLI_LOGIN_COMPLETED, login_props(outcome.switched_account));
        return;
    }
    let Some(sub) = hosted_sub(&outcome.state.account_id) else {
        return;
    };
    let guard = CLIENT.lock().unwrap_or_else(PoisonError::into_inner);
    let Some(client) = guard.as_ref() else {
        return;
    };
    consent::while_enabled(|| {
        if let Some(anon_id) = carried {
            client.alias(&sub, anon_id);
        }
        client.capture(
            CLI_LOGIN_COMPLETED,
            &sub,
            login_props(outcome.switched_account),
        );
    });
}

/// The on-disk part of [`login_completed`]: rotate the id when it is owed,
/// record the account, or mark the merge a non-sending run deferred. Returns
/// the carried id that is still to be merged into the account from here:
/// `None` when none was carried, or when another login bound the id to its
/// account while this one was in flight. That login rotated the id for its
/// account; the id this login carried is merged by the device-start request
/// alone, never a second time from here.
#[cfg(feature = "telemetry")]
fn settle_login(outcome: &LoginOutcome) -> Option<&str> {
    let mut carried = outcome.telemetry_anon_id.as_deref();
    // An opted-out login leaves the id, its account and the pending alias
    // as they were: the next run that may send finds the account changed
    // and rotates the id then, before anything is sent.
    if !consent::status().enabled {
        return None;
    }
    // The notice run and a run under the switch defer the merge of an
    // uncarried id, but only a hosted account is ever merged: for any other
    // account there is nothing owed, and the id stays the first hosted
    // login's to carry.
    let deferred =
        UNCARRIED.load(Ordering::Relaxed) && hosted_sub(&outcome.state.account_id).is_some();
    if consent::file_path().is_ok_and(|p| p.exists()) {
        let account = &outcome.state.account_id;
        // An id this login carried was claimed by [`login_anon_id`]: the
        // empty record is this login's own, not another account's. A login
        // that found this claim and could not rotate the id left it empty
        // too, and sent nothing under the id, so it is still no account's.
        let claimed_here = carried.is_some();
        let anothers = match anon_account() {
            AnonAccount::Unclaimed => false,
            AnonAccount::Bound(bound) if bound.is_empty() && claimed_here => false,
            AnonAccount::Bound(bound) => bound != *account,
            AnonAccount::Unknown => true,
        };
        if anothers {
            carried = None;
        }
        let owed = outcome.switched_account || anothers;
        // The account is recorded for the id only when the id is in play:
        // this run sends, carried it into the login, or left its merge
        // pending. A run that sends nothing (no key) records nothing, so
        // the id stays the first sending login's to carry.
        let in_play = SENDING.load(Ordering::Relaxed) || claimed_here || deferred;
        // An id that cannot be rotated must not keep sending: stop this
        // run's events rather than attribute them to the old account, and
        // record it as no account's. No account id is empty, so every later
        // run sees a mismatch and retries the rotation before it sends,
        // even where no account was recorded before.
        if owed && !rotate_existing_anon_id() {
            SENDING.store(false, Ordering::Relaxed);
            bind_anon_account("");
        } else if in_play && !bind_anon_account(account) {
            // The account is not on disk for this id: nothing more leaves
            // under it. A carried id keeps its claim, so no later login
            // carries it; the next run with an account settles it before
            // it sends.
            SENDING.store(false, Ordering::Relaxed);
        }
    }
    // The run that printed the notice, or ran under the switch, sends
    // nothing, so the merge waits for the next run that may send (see
    // `merge_pending_alias`).
    if deferred && !outcome.switched_account {
        if let Some(path) = pending_alias_path() {
            if let Err(e) = std::fs::write(path, b"") {
                tracing::warn!(error = %e, "telemetry alias marker not written");
            }
        }
    }
    carried
}

/// `cli_login_completed` properties.
pub fn login_props(is_account_switch: bool) -> Props {
    vec![("is_account_switch".into(), is_account_switch.into())]
}

/// Human wording for the rule that decided the status.
fn source_label(source: Source) -> &'static str {
    match source {
        Source::NotCompiled => "not compiled into this build",
        Source::DoNotTrack => "set by DO_NOT_TRACK",
        Source::EnvVar => "set by CERULION_TELEMETRY",
        Source::File => "set by `cerulion telemetry on|off`",
        Source::Default => "default",
    }
}

/// `cerulion telemetry status|on|off`.
pub fn run_verb(action: TelemetryAction, out: &mut impl Write) -> CliResult<()> {
    let io = |e: std::io::Error| CliError::Io(e);
    if !matches!(action, TelemetryAction::Status) {
        let enabled = matches!(action, TelemetryAction::On);
        if consent::status().source == Source::NotCompiled {
            writeln!(
                out,
                "telemetry is not compiled into this build; it never sends anything"
            )
            .map_err(io)?;
            return Ok(());
        }
        consent::set_enabled(enabled).map_err(|e| {
            CliError::Validation(format!("could not update the telemetry consent file: {e}"))
        })?;
    }
    let status = consent::status();
    let state = if status.enabled { "on" } else { "off" };
    writeln!(out, "telemetry: {state} ({})", source_label(status.source)).map_err(io)?;
    if matches!(status.source, Source::DoNotTrack | Source::EnvVar) {
        writeln!(
            out,
            "the environment variable overrides the consent file until it is unset"
        )
        .map_err(io)?;
    }
    #[cfg(feature = "telemetry")]
    {
        // The decision is the input; whether anything leaves depends on a
        // key. Say so, or "on (default)" reads as "data is being sent" on a
        // source build that sends nothing.
        if status.enabled && !key_present() {
            writeln!(out, "no telemetry key in this build: nothing is sent").map_err(io)?;
        }
        if let Ok(path) = consent::file_path() {
            writeln!(out, "consent file: {}", path.display()).map_err(io)?;
        }
    }
    writeln!(out, "what is sent: {DETAILS_URL}").map_err(io)?;
    Ok(())
}

/// Whether [`Client::from_env_or_key`] has a key to send with:
/// `POSTHOG_API_KEY` or the baked key, set and not blank, the same rule the
/// client applies.
#[cfg(feature = "telemetry")]
fn key_present() -> bool {
    let present = |k: &str| !k.trim().is_empty();
    match std::env::var("POSTHOG_API_KEY") {
        Ok(key) if present(&key) => true,
        Err(std::env::VarError::NotUnicode(_)) => false,
        _ => BAKED_KEY.is_some_and(present),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn durations_fall_into_coarse_buckets() {
        let b = |s| duration_bucket(Duration::from_secs(s));
        assert_eq!(duration_bucket(Duration::from_millis(999)), "lt_1s");
        assert_eq!(b(1), "1s_10s");
        assert_eq!(b(59), "10s_1m");
        assert_eq!(b(60), "1m_10m");
        assert_eq!(b(600), "gte_10m");
        assert_eq!(b(u64::MAX), "gte_10m");
    }

    #[test]
    fn exit_codes_map_to_their_numbers() {
        assert_eq!(exit_code_number(ExitCode::SUCCESS), 0);
        assert_eq!(exit_code_number(ExitCode::FAILURE), 1);
        assert_eq!(exit_code_number(ExitCode::from(7)), 7);
        assert_eq!(exit_code_number(ExitCode::from(255)), 255);
    }

    #[test]
    fn command_run_props_stay_inside_the_allowlist_and_pass_the_guard() {
        for (subverb, method) in [(None, None), (Some("run"), Some("install.sh"))] {
            let props =
                command_run_props("graph", subverb, ExitCode::FAILURE, Duration::ZERO, method);
            let (kept, dropped) = guard::filter(props.clone(), CLI_COMMAND_RUN.allowlist);
            assert!(dropped.is_empty(), "{dropped:?}");
            assert_eq!(kept, props);
        }
        guard::check_event_name(CLI_COMMAND_RUN.name).expect("event name");
        guard::check_event_name(CLI_LOGIN_COMPLETED.name).expect("event name");
        for switch in [false, true] {
            let (_, dropped) = guard::filter(login_props(switch), CLI_LOGIN_COMPLETED.allowlist);
            assert!(dropped.is_empty(), "{dropped:?}");
        }
        let c = common();
        for value in [&c.surface, &c.env, &c.app_version] {
            guard::check_str(value).expect(value);
        }
    }

    #[test]
    fn consent_completion_daemon_and_subprocess_verbs_are_unrecorded() {
        assert!(!is_recorded("telemetry", Some("off")));
        assert!(!is_recorded("completions", None));
        assert!(!is_recorded("bagd", None));
        assert!(!is_recorded("graph", Some("run-worker")));
        assert!(!is_recorded("graph", Some("run-gateway")));
        assert!(is_recorded("graph", Some("run")));
        assert!(is_recorded("login", None));
    }

    #[test]
    fn every_unrecorded_name_is_a_real_subcommand() {
        // The real tree, families included, so an unrecorded family verb is
        // checked against the command a user runs.
        let cli = crate::families::cli_command();
        let names: Vec<&str> = cli.get_subcommands().map(|c| c.get_name()).collect();
        for verb in UNRECORDED_VERBS {
            // `bagd` exists on every platform (a stub off Unix).
            assert!(names.contains(verb), "{verb}");
        }
        let graph = cli.find_subcommand("graph").expect("graph");
        for sub in UNRECORDED_SUBVERBS {
            assert!(graph.find_subcommand(sub).is_some(), "{sub}");
        }
    }
}