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").map(|v| !v.is_empty()).unwrap_or(false) {
37        return true;
38    }
39    if let Ok(state) = crate::config::state_dir()
40        && state.join("diag.enabled").exists()
41    {
42        return true;
43    }
44    false
45}
46
47/// Append a structured diag entry. No-op when disabled. Never panics,
48/// never propagates an error — diag is a best-effort breadcrumb, not
49/// a load-bearing channel. If the write fails, we'd rather lose the
50/// trace line than break the operation we were instrumenting.
51pub fn emit(event_type: &str, payload: Value) {
52    if !is_enabled() {
53        return;
54    }
55    let state = match crate::config::state_dir() {
56        Ok(s) => s,
57        Err(_) => return,
58    };
59    if std::fs::create_dir_all(&state).is_err() {
60        return;
61    }
62    let path = state.join("diag.jsonl");
63    // Rotation: rename to .1 if we'd cross the limit. Lossy single-
64    // generation — fine for a breadcrumb log.
65    if let Ok(meta) = std::fs::metadata(&path)
66        && meta.len() >= ROTATE_AT_BYTES
67    {
68        let _ = std::fs::rename(&path, state.join("diag.jsonl.1"));
69    }
70    let line = serde_json::json!({
71        "ts": std::time::SystemTime::now()
72            .duration_since(std::time::UNIX_EPOCH)
73            .map(|d| d.as_secs())
74            .unwrap_or(0),
75        "pid": std::process::id(),
76        "version": env!("CARGO_PKG_VERSION"),
77        "type": event_type,
78        "payload": payload,
79    });
80    let bytes = match serde_json::to_vec(&line) {
81        Ok(mut b) => {
82            b.push(b'\n');
83            b
84        }
85        Err(_) => return,
86    };
87    use std::io::Write;
88    if let Ok(mut f) = std::fs::OpenOptions::new()
89        .create(true)
90        .append(true)
91        .open(&path)
92    {
93        let _ = f.write_all(&bytes);
94    }
95}
96
97/// Read the last `n` lines from diag.jsonl. Returns parsed JSON lines;
98/// malformed entries are skipped silently. Used by `wire doctor --tail
99/// diag` (or `wire diag tail`).
100pub fn tail(n: usize) -> Vec<Value> {
101    let path = match crate::config::state_dir() {
102        Ok(s) => s.join("diag.jsonl"),
103        Err(_) => return Vec::new(),
104    };
105    let body = match std::fs::read_to_string(&path) {
106        Ok(b) => b,
107        Err(_) => return Vec::new(),
108    };
109    let mut out: Vec<Value> = body
110        .lines()
111        .filter(|l| !l.trim().is_empty())
112        .filter_map(|l| serde_json::from_str(l).ok())
113        .collect();
114    let start = out.len().saturating_sub(n);
115    out.drain(..start);
116    out
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use serde_json::json;
123
124    #[test]
125    fn diag_is_noop_when_disabled() {
126        crate::config::test_support::with_temp_home(|| {
127            // Default state: WIRE_DIAG unset, diag.enabled file absent.
128            assert!(!is_enabled());
129            emit("pull", json!({"events": 3}));
130            let state = crate::config::state_dir().unwrap();
131            let diag = state.join("diag.jsonl");
132            assert!(!diag.exists(), "diag must not write when disabled");
133        });
134    }
135
136    #[test]
137    fn diag_emits_when_env_var_set() {
138        crate::config::test_support::with_temp_home(|| {
139            crate::config::ensure_dirs().unwrap();
140            // SAFETY: test_support::with_temp_home holds ENV_LOCK.
141            unsafe { std::env::set_var("WIRE_DIAG", "1") };
142            emit("pull", json!({"events": 2, "rejected": 0}));
143            unsafe { std::env::remove_var("WIRE_DIAG") };
144            let lines = tail(10);
145            assert_eq!(lines.len(), 1);
146            assert_eq!(lines[0]["type"], "pull");
147            assert_eq!(lines[0]["payload"]["events"], 2);
148            assert!(lines[0]["ts"].as_u64().is_some());
149            assert!(lines[0]["pid"].as_u64().is_some());
150        });
151    }
152
153    #[test]
154    fn diag_emits_when_file_knob_present() {
155        // File knob: operators can flip diag on for a running daemon
156        // without restarting it.
157        crate::config::test_support::with_temp_home(|| {
158            crate::config::ensure_dirs().unwrap();
159            let state = crate::config::state_dir().unwrap();
160            std::fs::write(state.join("diag.enabled"), "1").unwrap();
161            assert!(is_enabled());
162            emit("push", json!({"peer": "willard"}));
163            let lines = tail(10);
164            assert_eq!(lines.len(), 1);
165            assert_eq!(lines[0]["type"], "push");
166        });
167    }
168
169    #[test]
170    fn diag_tail_returns_last_n_entries_in_order() {
171        crate::config::test_support::with_temp_home(|| {
172            crate::config::ensure_dirs().unwrap();
173            unsafe { std::env::set_var("WIRE_DIAG", "1") };
174            for i in 0..5u32 {
175                emit("test", json!({"i": i}));
176            }
177            unsafe { std::env::remove_var("WIRE_DIAG") };
178            let lines = tail(3);
179            assert_eq!(lines.len(), 3);
180            // Order preserved.
181            assert_eq!(lines[0]["payload"]["i"], 2);
182            assert_eq!(lines[1]["payload"]["i"], 3);
183            assert_eq!(lines[2]["payload"]["i"], 4);
184        });
185    }
186}