Skip to main content

wire/
diag.rs

1//! P2.10 (0.5.11): structured diagnostic trace.
2//!
3//! Optional append-only JSONL log at
4//! `$WIRE_HOME/state/wire/diag.jsonl`. Every meaningful wire op
5//! (pull, push, pair transition, daemon spawn, schema rejection)
6//! emits one line. The previous "what is this binary actually
7//! doing right now" question required pgrep + strace + dtruss —
8//! now it's `wire diag tail`.
9//!
10//! Off by default. Enable per-process via env `WIRE_DIAG=1`, or
11//! per-machine via writing the file `$WIRE_HOME/state/wire/diag.enabled`
12//! (any non-empty content). Either signal flips emit() from a no-op
13//! to a real write — the env knob is good for one-off CLI sessions,
14//! the file knob is good for daemons that operators want to keep
15//! tracing across restarts without modifying their launchd plist.
16//!
17//! Cost: a single `OpenOptions::append(true)` + `write_all` per
18//! event when enabled, no-op otherwise. The hot path checks one env
19//! var + one file metadata stat.
20
21use serde_json::Value;
22
23/// Maximum diag.jsonl size before rotation. 8 MiB is enough to keep
24/// ~50,000 typical-shape entries while staying under operator-friendly
25/// `tail`/`grep` budgets. Past this, the file is renamed to
26/// `diag.jsonl.1` (clobbering any prior rotation) and a new file
27/// starts. One generation of history is enough — diag is a debugging
28/// breadcrumb trail, not an archive.
29const ROTATE_AT_BYTES: u64 = 8 * 1024 * 1024;
30
31/// True if diag emission is enabled for THIS process invocation.
32///
33/// Checked on every call (not cached) so an operator can toggle the
34/// file-based knob mid-session without restarting their daemon.
35pub fn is_enabled() -> bool {
36    if std::env::var("WIRE_DIAG")
37        .map(|v| !v.is_empty())
38        .unwrap_or(false)
39    {
40        return true;
41    }
42    if let Ok(state) = crate::config::state_dir()
43        && state.join("diag.enabled").exists()
44    {
45        return true;
46    }
47    false
48}
49
50/// Append a structured diag entry. No-op when disabled. Never panics,
51/// never propagates an error — diag is a best-effort breadcrumb, not
52/// a load-bearing channel. If the write fails, we'd rather lose the
53/// trace line than break the operation we were instrumenting.
54pub fn emit(event_type: &str, payload: Value) {
55    if !is_enabled() {
56        return;
57    }
58    let state = match crate::config::state_dir() {
59        Ok(s) => s,
60        Err(_) => return,
61    };
62    if std::fs::create_dir_all(&state).is_err() {
63        return;
64    }
65    let path = state.join("diag.jsonl");
66    // Rotation: rename to .1 if we'd cross the limit. Lossy single-
67    // generation — fine for a breadcrumb log.
68    if let Ok(meta) = std::fs::metadata(&path)
69        && meta.len() >= ROTATE_AT_BYTES
70    {
71        let _ = std::fs::rename(&path, state.join("diag.jsonl.1"));
72    }
73    let line = serde_json::json!({
74        "ts": std::time::SystemTime::now()
75            .duration_since(std::time::UNIX_EPOCH)
76            .map(|d| d.as_secs())
77            .unwrap_or(0),
78        "pid": std::process::id(),
79        "version": env!("CARGO_PKG_VERSION"),
80        "type": event_type,
81        "payload": payload,
82    });
83    let bytes = match serde_json::to_vec(&line) {
84        Ok(mut b) => {
85            b.push(b'\n');
86            b
87        }
88        Err(_) => return,
89    };
90    use std::io::Write;
91    if let Ok(mut f) = std::fs::OpenOptions::new()
92        .create(true)
93        .append(true)
94        .open(&path)
95    {
96        let _ = f.write_all(&bytes);
97    }
98}
99
100/// Read the last `n` lines from diag.jsonl. Returns parsed JSON lines;
101/// malformed entries are skipped silently. Used by `wire doctor --tail
102/// diag` (or `wire diag tail`).
103pub fn tail(n: usize) -> Vec<Value> {
104    let path = match crate::config::state_dir() {
105        Ok(s) => s.join("diag.jsonl"),
106        Err(_) => return Vec::new(),
107    };
108    let body = match std::fs::read_to_string(&path) {
109        Ok(b) => b,
110        Err(_) => return Vec::new(),
111    };
112    let mut out: Vec<Value> = body
113        .lines()
114        .filter(|l| !l.trim().is_empty())
115        .filter_map(|l| serde_json::from_str(l).ok())
116        .collect();
117    let start = out.len().saturating_sub(n);
118    out.drain(..start);
119    out
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use serde_json::json;
126
127    #[test]
128    fn diag_is_noop_when_disabled() {
129        crate::config::test_support::with_temp_home(|| {
130            // Default state: WIRE_DIAG unset, diag.enabled file absent.
131            assert!(!is_enabled());
132            emit("pull", json!({"events": 3}));
133            let state = crate::config::state_dir().unwrap();
134            let diag = state.join("diag.jsonl");
135            assert!(!diag.exists(), "diag must not write when disabled");
136        });
137    }
138
139    #[test]
140    fn diag_emits_when_env_var_set() {
141        crate::config::test_support::with_temp_home(|| {
142            crate::config::ensure_dirs().unwrap();
143            // SAFETY: test_support::with_temp_home holds ENV_LOCK.
144            unsafe { std::env::set_var("WIRE_DIAG", "1") };
145            emit("pull", json!({"events": 2, "rejected": 0}));
146            unsafe { std::env::remove_var("WIRE_DIAG") };
147            let lines = tail(10);
148            assert_eq!(lines.len(), 1);
149            assert_eq!(lines[0]["type"], "pull");
150            assert_eq!(lines[0]["payload"]["events"], 2);
151            assert!(lines[0]["ts"].as_u64().is_some());
152            assert!(lines[0]["pid"].as_u64().is_some());
153        });
154    }
155
156    #[test]
157    fn diag_emits_when_file_knob_present() {
158        // File knob: operators can flip diag on for a running daemon
159        // without restarting it.
160        crate::config::test_support::with_temp_home(|| {
161            crate::config::ensure_dirs().unwrap();
162            let state = crate::config::state_dir().unwrap();
163            std::fs::write(state.join("diag.enabled"), "1").unwrap();
164            assert!(is_enabled());
165            emit("push", json!({"peer": "willard"}));
166            let lines = tail(10);
167            assert_eq!(lines.len(), 1);
168            assert_eq!(lines[0]["type"], "push");
169        });
170    }
171
172    #[test]
173    fn diag_tail_returns_last_n_entries_in_order() {
174        crate::config::test_support::with_temp_home(|| {
175            crate::config::ensure_dirs().unwrap();
176            unsafe { std::env::set_var("WIRE_DIAG", "1") };
177            for i in 0..5u32 {
178                emit("test", json!({"i": i}));
179            }
180            unsafe { std::env::remove_var("WIRE_DIAG") };
181            let lines = tail(3);
182            assert_eq!(lines.len(), 3);
183            // Order preserved.
184            assert_eq!(lines[0]["payload"]["i"], 2);
185            assert_eq!(lines[1]["payload"]["i"], 3);
186            assert_eq!(lines[2]["payload"]["i"], 4);
187        });
188    }
189}