1#![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)]
27pub enum Outcome {
29 Success,
31 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)]
45pub 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 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 pub fn session_id(&self) -> &str {
105 &self.session_id
106 }
107
108 pub fn project_id(&self) -> &str {
112 &self.project_id
113 }
114
115 pub fn write_start(&self) {
117 self.write_event("command_start", None, None);
118 }
119
120 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}