Skip to main content

ito_logging/
lib.rs

1//! Telemetry logging for Ito.
2//!
3//! This crate records low-volume execution events to a JSONL file under the
4//! user's config directory. The output is designed to be append-only and
5//! resilient: failures to read/write telemetry should never break the main
6//! command flow.
7//!
8//! The logger intentionally stores only coarse metadata:
9//! - a stable-but-anonymized `project_id` derived from a per-user salt
10//! - a `session_id` persisted under `.ito/session.json` when available
11
12#![warn(missing_docs)]
13
14use chrono::{SecondsFormat, Utc};
15use rand::RngCore;
16use serde::{Deserialize, Serialize};
17use sha2::Digest;
18use std::fs::OpenOptions;
19use std::io::Write;
20use std::path::{Path, PathBuf};
21use std::time::Duration;
22
23const EVENT_VERSION: u32 = 1;
24const SALT_FILE_NAME: &str = "telemetry_salt";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27/// High-level command outcome used for telemetry.
28pub enum Outcome {
29    /// Command finished successfully.
30    Success,
31    /// Command finished with an error.
32    Error,
33}
34
35impl Outcome {
36    fn as_str(self) -> &'static str {
37        match self {
38            Outcome::Success => "success",
39            Outcome::Error => "error",
40        }
41    }
42}
43
44#[derive(Debug, Clone)]
45/// Append-only telemetry logger.
46///
47/// Construct with [`Logger::new`]. When logging is disabled or cannot be
48/// initialized, `new` returns `None`.
49pub struct Logger {
50    file_path: PathBuf,
51    ito_version: String,
52    command_id: String,
53    session_id: String,
54    project_id: String,
55    pid: u32,
56}
57
58impl Logger {
59    /// Create a logger if telemetry is enabled.
60    ///
61    /// Returns `None` when:
62    /// - telemetry is disabled (`ITO_DISABLE_LOGGING`)
63    /// - the config directory is not available
64    /// - the telemetry salt cannot be read/created
65    pub fn new(
66        config_dir: Option<PathBuf>,
67        project_root: &Path,
68        ito_path: Option<&Path>,
69        command_id: &str,
70        ito_version: &str,
71    ) -> Option<Self> {
72        if logging_disabled() {
73            log::debug!("telemetry: disabled by ITO_DISABLE_LOGGING");
74            return None;
75        }
76
77        let config_dir = config_dir?;
78        let salt_path = config_dir.join(SALT_FILE_NAME);
79        let salt = load_or_create_salt(&salt_path)?;
80        let project_id = compute_project_id(&salt, project_root);
81        let session_id = resolve_session_id(ito_path);
82        let file_path = log_file_path(&config_dir, &project_id, &session_id);
83
84        if let Some(parent) = file_path.parent()
85            && let Err(e) = std::fs::create_dir_all(parent)
86        {
87            log::debug!("telemetry: create_dir_all failed: {e}");
88        }
89
90        Some(Self {
91            file_path,
92            ito_version: ito_version.to_string(),
93            command_id: command_id.to_string(),
94            session_id,
95            project_id,
96            pid: std::process::id(),
97        })
98    }
99
100    /// Session identifier for this execution.
101    ///
102    /// When an `.ito/` directory exists, this is persisted in
103    /// `.ito/session.json` to allow grouping commands across runs.
104    pub fn session_id(&self) -> &str {
105        &self.session_id
106    }
107
108    /// Stable anonymized project identifier.
109    ///
110    /// This is derived from `project_root` using a per-user random salt.
111    pub fn project_id(&self) -> &str {
112        &self.project_id
113    }
114
115    /// Write a `command_start` event.
116    pub fn write_start(&self) {
117        self.write_event("command_start", None, None);
118    }
119
120    /// Write a `command_end` event.
121    pub fn write_end(&self, outcome: Outcome, duration: Duration) {
122        let duration_ms = duration.as_millis();
123        let duration_ms = u64::try_from(duration_ms).unwrap_or(u64::MAX);
124        self.write_event("command_end", Some(outcome), Some(duration_ms));
125    }
126
127    fn write_event(
128        &self,
129        event_type: &'static str,
130        outcome: Option<Outcome>,
131        duration_ms: Option<u64>,
132    ) {
133        #[derive(Serialize)]
134        struct Event {
135            event_version: u32,
136            event_id: String,
137            timestamp: String,
138            event_type: &'static str,
139            ito_version: String,
140            command_id: String,
141            session_id: String,
142            project_id: String,
143            pid: u32,
144            #[serde(skip_serializing_if = "Option::is_none")]
145            outcome: Option<String>,
146            #[serde(skip_serializing_if = "Option::is_none")]
147            duration_ms: Option<u64>,
148        }
149
150        let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
151        let event = Event {
152            event_version: EVENT_VERSION,
153            event_id: uuid::Uuid::new_v4().to_string(),
154            timestamp,
155            event_type,
156            ito_version: self.ito_version.clone(),
157            command_id: self.command_id.clone(),
158            session_id: self.session_id.clone(),
159            project_id: self.project_id.clone(),
160            pid: self.pid,
161            outcome: outcome.map(|o| o.as_str().to_string()),
162            duration_ms,
163        };
164
165        let Ok(line) = serde_json::to_string(&event) else {
166            log::debug!("telemetry: failed to serialize event");
167            return;
168        };
169
170        let Ok(mut f) = OpenOptions::new()
171            .create(true)
172            .append(true)
173            .open(&self.file_path)
174        else {
175            log::debug!("telemetry: failed to open log file");
176            return;
177        };
178        if let Err(e) = writeln!(f, "{line}") {
179            log::debug!("telemetry: failed to append log line: {e}");
180        }
181    }
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
185struct SessionState {
186    session_id: String,
187    created_at: String,
188}
189
190fn resolve_session_id(ito_path: Option<&Path>) -> String {
191    let session_id = new_session_id();
192
193    let Some(ito_path) = ito_path else {
194        return session_id;
195    };
196    if !ito_path.is_dir() {
197        return session_id;
198    }
199
200    let path = ito_path.join("session.json");
201    if let Ok(contents) = std::fs::read_to_string(&path) {
202        match serde_json::from_str::<SessionState>(&contents) {
203            Ok(SessionState {
204                session_id,
205                created_at: _,
206            }) if is_safe_session_id(&session_id) => {
207                return session_id;
208            }
209            Ok(_) => {}
210            Err(e) => {
211                log::debug!("telemetry: failed to parse session.json: {e}");
212            }
213        }
214    }
215
216    let created_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true);
217    let state = SessionState {
218        session_id: session_id.clone(),
219        created_at,
220    };
221    if let Ok(contents) = serde_json::to_string(&state)
222        && let Err(e) = std::fs::write(&path, contents)
223    {
224        log::debug!("telemetry: failed to write session.json: {e}");
225    }
226
227    session_id
228}
229
230fn new_session_id() -> String {
231    let ts = Utc::now().timestamp();
232    let rand = uuid::Uuid::new_v4().simple().to_string();
233    format!("{ts}-{rand}")
234}
235
236fn is_safe_session_id(session_id: &str) -> bool {
237    let session_id = session_id.trim();
238    if session_id.is_empty() {
239        return false;
240    }
241    if session_id.len() > 128 {
242        return false;
243    }
244    if session_id.contains('/') || session_id.contains('\\') || session_id.contains("..") {
245        return false;
246    }
247
248    for c in session_id.chars() {
249        if c.is_ascii_alphanumeric() || c == '-' {
250            continue;
251        }
252        return false;
253    }
254
255    true
256}
257
258fn log_file_path(config_dir: &Path, project_id: &str, session_id: &str) -> PathBuf {
259    config_dir
260        .join("logs")
261        .join("execution")
262        .join("v1")
263        .join("projects")
264        .join(project_id)
265        .join("sessions")
266        .join(format!("{session_id}.jsonl"))
267}
268
269fn canonicalize_best_effort(path: &Path) -> PathBuf {
270    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
271}
272
273fn compute_project_id(salt: &[u8; 32], project_root: &Path) -> String {
274    let root = canonicalize_best_effort(project_root);
275    let root = root.to_string_lossy();
276
277    let mut hasher = sha2::Sha256::new();
278    hasher.update(salt);
279    hasher.update([0u8]);
280    hasher.update(root.as_bytes());
281    let digest = hasher.finalize();
282
283    hex::encode(digest)
284}
285
286fn load_or_create_salt(path: &Path) -> Option<[u8; 32]> {
287    if let Ok(bytes) = std::fs::read(path)
288        && bytes.len() == 32
289    {
290        let mut out = [0u8; 32];
291        out.copy_from_slice(&bytes);
292        return Some(out);
293    }
294
295    if path.exists() {
296        log::debug!("telemetry: telemetry_salt had unexpected length");
297    }
298
299    if let Some(parent) = path.parent() {
300        let _ = std::fs::create_dir_all(parent);
301    }
302
303    let mut out = [0u8; 32];
304    rand::rng().fill_bytes(&mut out);
305    if let Err(e) = std::fs::write(path, out) {
306        log::debug!("telemetry: failed to write telemetry_salt: {e}");
307        return None;
308    }
309
310    Some(out)
311}
312
313#[allow(clippy::match_like_matches_macro)]
314fn logging_disabled() -> bool {
315    let Some(v) = std::env::var_os("ITO_DISABLE_LOGGING") else {
316        return false;
317    };
318    let v = v.to_string_lossy();
319    let v = v.trim().to_ascii_lowercase();
320    match v.as_str() {
321        "1" | "true" | "yes" => true,
322        _ => false,
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn unsafe_session_ids_are_rejected() {
332        assert!(!is_safe_session_id(""));
333        assert!(!is_safe_session_id("../escape"));
334        assert!(!is_safe_session_id("a/b"));
335        assert!(!is_safe_session_id("abc def"));
336        assert!(is_safe_session_id(
337            "1739330000-550e8400e29b41d4a716446655440000"
338        ));
339    }
340}