Skip to main content

codewhale_telemetry/
envelope.rs

1//! Install identity and the constant half of the batch envelope.
2
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7
8use crate::buffer;
9use crate::event::{Arch, Libc, Os};
10
11/// How long an install id may live before it is replaced.
12///
13/// A never-rotating id plus one batch per session from the user's IP is a
14/// longitudinal IP and travel trace. Rotation bounds that join. It costs
15/// longitudinal accuracy, and the docs say so in those words: **no count derived
16/// from `install_id` is a user count.**
17pub const ROTATION_DAYS: i64 = 90;
18
19/// The on-disk install identity.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct InstallId {
22    /// Format version of this file.
23    pub schema_version: u32,
24    /// A random v4 UUID.
25    ///
26    /// Never derived from hostname, MAC, `machine-id`, `$HOME`, username, or
27    /// executable path. A derived id is a device fingerprint: it survives
28    /// reinstall and re-identifies a user across their own opt-out, which is the
29    /// single thing an install id must never do.
30    pub install_id: String,
31    /// When this id was minted, RFC3339 UTC.
32    pub rotated_at: String,
33}
34
35/// Per-machine telemetry bookkeeping. Never contains anything about the user's
36/// work — only what this crate needs to avoid re-reporting an install and to
37/// rate-limit its own flushes.
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39pub struct TelemetryState {
40    /// Format version of this file.
41    #[serde(default)]
42    pub schema_version: u32,
43    /// The app version last seen on this machine.
44    #[serde(default)]
45    pub last_version: Option<String>,
46    /// When a flush was last *attempted*, RFC3339 UTC. Attempt, not success, so
47    /// a permanently offline machine tries at most once per interval.
48    #[serde(default)]
49    pub last_flush: Option<String>,
50}
51
52/// Read the install id, minting a fresh one if it is missing, unreadable,
53/// **not a UUID**, or older than [`ROTATION_DAYS`].
54///
55/// The UUID check is not a formatting nicety. `install_id` is the one
56/// envelope field read verbatim off disk into a batch, so without it the file
57/// is a free-form string slot on the wire for anything that can write
58/// `$CODEWHALE_HOME/telemetry/install_id.json`. Minting a fresh random id is
59/// always the safe direction — the cost is one rotation, and the docs already
60/// say no count derived from `install_id` is a user count.
61pub fn read_or_create_install_id(root: &Path) -> Result<InstallId> {
62    buffer::try_with_lock(root, || {
63        if buffer::tombstone_present(root) {
64            anyhow::bail!("telemetry is disabled");
65        }
66        let path = buffer::install_id_path(root);
67        let existing = std::fs::read_to_string(&path)
68            .ok()
69            .and_then(|body| serde_json::from_str::<InstallId>(&body).ok())
70            .filter(|record| uuid::Uuid::parse_str(record.install_id.trim()).is_ok())
71            .filter(|record| !is_expired(&record.rotated_at));
72        if let Some(record) = existing {
73            return Ok(record);
74        }
75        let record = InstallId {
76            schema_version: 1,
77            install_id: uuid::Uuid::new_v4().to_string(),
78            rotated_at: now_rfc3339(),
79        };
80        codewhale_config::persistence::atomic_write_json(&path, &record)
81            .with_context(|| format!("failed to write {}", path.display()))?;
82        Ok(record)
83    })?
84    .ok_or_else(|| anyhow::anyhow!("telemetry privacy lock is held"))
85}
86
87fn is_expired(rotated_at: &str) -> bool {
88    let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(rotated_at) else {
89        // An unreadable timestamp is treated as expired: minting a fresh random
90        // id is always the safe direction.
91        return true;
92    };
93    let age = chrono::Utc::now().signed_duration_since(parsed.with_timezone(&chrono::Utc));
94    age.num_days() >= ROTATION_DAYS
95}
96
97/// Read `state.json`, or a default when it is missing or unreadable.
98#[must_use]
99pub fn read_state(root: &Path) -> TelemetryState {
100    std::fs::read_to_string(buffer::state_path(root))
101        .ok()
102        .and_then(|body| serde_json::from_str::<TelemetryState>(&body).ok())
103        .unwrap_or_default()
104}
105
106/// Write `state.json`.
107pub fn write_state(root: &Path, state: &TelemetryState) -> Result<()> {
108    buffer::try_with_lock(root, || {
109        if buffer::tombstone_present(root) {
110            anyhow::bail!("telemetry is disabled");
111        }
112        let path = buffer::state_path(root);
113        codewhale_config::persistence::atomic_write_json(&path, state)
114            .with_context(|| format!("failed to write {}", path.display()))
115    })?
116    .ok_or_else(|| anyhow::anyhow!("telemetry privacy lock is held"))
117}
118
119/// RFC3339 UTC at second precision. The only timestamp this crate produces, and
120/// it is per-**batch**: individual events carry no timestamps at all.
121#[must_use]
122pub fn now_rfc3339() -> String {
123    chrono::Utc::now()
124        .to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
125        .to_string()
126}
127
128/// The build sha of a release-CI binary, or `None`.
129///
130/// Sourced from `CODEWHALE_RELEASE_BUILD_SHA`, a rustc-env this crate's build
131/// script emits **only** when `DEEPSEEK_BUILD_SHA` or `GITHUB_SHA` was present
132/// in the build environment. `null` for every locally built binary,
133/// unconditionally, with no runtime lookup of any kind.
134///
135/// Never `CODEWHALE_BUILD_COMMIT` — that falls back to the builder's own `HEAD`
136/// on a local build. Never `Thread.git_sha` — that is the *user's* workspace
137/// commit and a red line, one identifier away by name.
138#[must_use]
139pub fn release_build_sha() -> Option<String> {
140    option_env!("CODEWHALE_RELEASE_BUILD_SHA").and_then(short_hex_sha)
141}
142
143/// Reduce a full sha to the first 12 lowercase hex characters, rejecting
144/// anything that is not a sha.
145#[must_use]
146pub fn short_hex_sha(value: &str) -> Option<String> {
147    let trimmed = value.trim().to_ascii_lowercase();
148    if trimmed.len() < 12 || !trimmed.bytes().all(|b| b.is_ascii_hexdigit()) {
149        return None;
150    }
151    Some(trimmed.chars().take(12).collect())
152}
153
154/// The OS family this binary is running on, mapped onto the closed whitelist.
155#[must_use]
156pub fn current_os() -> Os {
157    match std::env::consts::OS {
158        "linux" => Os::Linux,
159        "macos" => Os::Macos,
160        "windows" => Os::Windows,
161        "freebsd" => Os::Freebsd,
162        "android" => Os::Android,
163        _ => Os::Other,
164    }
165}
166
167/// The CPU family, mapped onto the closed whitelist.
168#[must_use]
169pub fn current_arch() -> Arch {
170    match std::env::consts::ARCH {
171        "x86_64" => Arch::X86_64,
172        "aarch64" => Arch::Aarch64,
173        _ => Arch::Other,
174    }
175}
176
177/// The libc this binary was **compiled** against.
178#[must_use]
179pub fn current_libc() -> Libc {
180    if cfg!(target_env = "gnu") {
181        Libc::Gnu
182    } else if cfg!(target_env = "musl") {
183        Libc::Musl
184    } else {
185        Libc::None
186    }
187}
188
189/// Whether both stdin and stdout are terminals.
190///
191/// This varies because consent is machine-scoped: a decision recorded on a TTY
192/// authorizes later headless runs on the same home.
193#[must_use]
194pub fn current_tty() -> bool {
195    use std::io::IsTerminal as _;
196    std::io::stdin().is_terminal() && std::io::stdout().is_terminal()
197}
198
199/// Reduce a panic location to something that is safe to send.
200///
201/// Emit a `crates/…` path verbatim; reduce **everything else** to the literal
202/// `<dep>`. There is no `--remap-path-prefix` in this repo, so a panic inside a
203/// registry dependency yields
204/// `/Users/<builder>/.cargo/registry/src/…/ratatui-0.29.0/src/…` — the build
205/// machine's username, shipped from every user's binary.
206/// The allowlist itself lives in [`crate::event::is_reduced_panic_site`], and
207/// this function is defined as "the candidate if the predicate accepts it".
208/// Two copies of one charset would drift, and the drain path re-checks the
209/// predicate against events read back off disk — a reducer that could emit
210/// something the checker rejects would silently delete real panics.
211#[must_use]
212pub fn reduce_panic_site(file: &str, line: u32, column: u32) -> String {
213    let candidate = format!("{}:{line}:{column}", file.replace('\\', "/"));
214    if crate::event::is_reduced_panic_site(&candidate) {
215        candidate
216    } else {
217        "<dep>".to_string()
218    }
219}