Skip to main content

zsh/extensions/
log.rs

1//! zshrs logging & profiling framework.
2//!
3//! **zshrs-original infrastructure — no C source counterpart.** C
4//! zsh emits all diagnostics through `zwarn()` / `zwarnnam()` /
5//! `zerr()` / `zerrnam()` in Src/utils.c, which print directly to
6//! stderr. This module replaces that pattern with a structured
7//! `tracing`-based subscriber that writes to `~/.zshrs/zshrs.log`.
8//! Per the project's "no startup chatter" rule, all init progress
9//! goes here instead of stderr.
10//!
11//! **Logging** (always on):
12//!   - File: `$HOME/.zshrs/{binary}.log` — five separate files so the
13//!     processes don't interleave their tracing output:
14//!       - `zshrs.log`           (the shell — interactive / scripted)
15//!       - `zshrs-lsp.log`       (`zshrs --lsp` — IDE language server)
16//!       - `zshrs-dap.log`       (`zshrs --dap` — IDE debug adapter)
17//!       - `zshrs-daemon.log`    (daemon, set up via daemon/log.rs)
18//!       - `zshrs-recorder.log`  (single-shot recorder runs)
19//!     The IDE plugin side has its own `zshrs-plugin.log` written by
20//!     `editors/intellij/.../ZshrsDebugLog.kt`.
21//!   - Level: ZSHRS_LOG env var (default: info)
22//!   - Structured key=value fields, ISO timestamps, thread names, module paths
23//!
24//! **Profiling** (feature-gated, zero cost when off):
25//!   - `--features profiling`  → chrome://tracing JSON  → $HOME/.zshrs/trace-{PID}.json
26//!   - `--features flamegraph` → folded stacks          → $HOME/.zshrs/flame-{PID}.folded
27//!   - `--features prometheus` → metrics on :9090/metrics
28//!
29//! Call `zsh::log::init()` (defaults to `zshrs.log`) or
30//! `zsh::log::init_named("…")` once at startup. Use
31//! `tracing::{info,debug,trace,warn,error}!` everywhere. Use
32//! `#[tracing::instrument]` or `zsh::log::span!` for timed sections.
33
34use std::path::PathBuf;
35use std::sync::OnceLock;
36use tracing_subscriber::prelude::*;
37
38/// Guards that must live for the duration of the process.
39/// Dropping any of these flushes and stops the associated writer.
40struct Guards {
41    #[cfg(feature = "profiling")]
42    _chrome: tracing_chrome::FlushGuard,
43    #[cfg(feature = "flamegraph")]
44    _flame: tracing_flame::FlushGuard<std::io::BufWriter<std::fs::File>>,
45}
46
47static GUARDS: OnceLock<Guards> = OnceLock::new();
48
49/// Resolve log/profile output directory: `$ZSHRS_HOME` or
50/// `$HOME/.zshrs`.
51/// zshrs-original — no C counterpart. C zsh has no log file at all
52/// (it writes to stderr).
53pub fn log_dir() -> PathBuf {
54    if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
55        return PathBuf::from(custom);
56    }
57    dirs::home_dir()
58        .unwrap_or_else(|| PathBuf::from("/tmp"))
59        .join(".zshrs")
60}
61
62/// Resolve the full log path for the shell binary
63/// (`zshrs.log`).
64/// zshrs-original — no C counterpart.
65pub fn log_path() -> PathBuf {
66    log_dir().join("zshrs.log")
67}
68
69/// Initialize logging + optional profiling subscribers using the
70/// default `zshrs.log` filename. Equivalent to
71/// `init_named("zshrs.log")`. Safe to call multiple times — only
72/// the first call takes effect.
73/// zshrs-original — no C counterpart. C zsh's `Src/init.c`
74/// `setupshin()` doesn't open a log file.
75///
76/// Env vars:
77///   ZSHRS_LOG=debug|trace|info|warn|error  (default: info)
78pub fn init() {
79    init_named("zshrs.log");
80}
81
82/// Initialize logging + optional profiling subscribers, writing to
83/// `$ZSHRS_HOME/<filename>` (or `$HOME/.zshrs/<filename>`). Used
84/// by `zshrs-recorder` to land tracing in `zshrs-recorder.log`
85/// instead of the shell's `zshrs.log`. The daemon owns its own
86/// subscriber via `daemon/log.rs` and uses the path from
87/// `paths.log_file_name`.
88/// zshrs-original — no C counterpart.
89///
90/// Safe to call multiple times — only the first call takes effect.
91pub fn init_named(filename: &str) {
92    GUARDS.get_or_init(|| {
93        let dir = log_dir();
94        let _ = std::fs::create_dir_all(&dir);
95        let pid = std::process::id();
96
97        // --- File log layer (always on) ---
98        // Use a blocking Mutex<File> writer — log writes are microseconds and this
99        // guarantees data reaches disk even when std::process::exit() skips destructors.
100        // c:Src/utils.c:1990 movefd — the shell's own descriptors must not sit
101        // in the script's fd range. Without this the log landed on fd 3, so
102        // `print -u 3 -r -- x` appended to it (and reported success), and
103        // `exec 3>file` would dup2 over the live log handle.
104        let _lowfd = crate::lowfd::LowFdGuard::new();
105        // Size-capped rotation at init (client-side, no background
106        // threads — the daemon owns scheduled rotation, but daemonless
107        // invocations must still bound the file). Millions of short
108        // `-fc` runs (parity fuzz harnesses) each append startup lines;
109        // unrotated this reached 18 GB / 142M lines and exhausted the
110        // disk. One stat() per shell start; over the cap the current
111        // file becomes `<name>.1` (previous `.1` is replaced) and a
112        // fresh file starts. rename() keeps writers on the old inode
113        // consistent (they finish writing to `.1`).
114        const LOG_ROTATE_BYTES: u64 = 512 * 1024 * 1024;
115        let log_path = dir.join(filename);
116        if std::fs::metadata(&log_path)
117            .map(|m| m.len() > LOG_ROTATE_BYTES)
118            .unwrap_or(false)
119        {
120            let _ = std::fs::rename(&log_path, dir.join(format!("{filename}.1")));
121        }
122        let log_file = std::fs::OpenOptions::new()
123            .create(true)
124            .append(true)
125            .open(&log_path)
126            .unwrap_or_else(|_| {
127                std::fs::OpenOptions::new()
128                    .create(true)
129                    .append(true)
130                    .open(format!("/tmp/{}", filename))
131                    .expect("cannot open any log file")
132            });
133        let log_writer = std::sync::Mutex::new(log_file);
134
135        // Filter precedence:
136        //   1. $ZSHRS_LOG (env var, ad-hoc / one-shot debugging)
137        //   2. [log] level in $ZSHRS_HOME/zshrs.toml (the persistent
138        //      knob — set once, no env var needed every session)
139        //   3. "info" hard default
140        let env_filter = std::env::var("ZSHRS_LOG")
141            .unwrap_or_else(|_| crate::daemon_presence::read_log_directive());
142
143        let file_layer = tracing_subscriber::fmt::layer()
144            .with_writer(log_writer)
145            .with_ansi(false)
146            .with_target(true)
147            .with_thread_names(true)
148            .compact();
149
150        // --- Chrome tracing layer (--features profiling) ---
151        #[cfg(feature = "profiling")]
152        let (chrome_layer, chrome_guard) = {
153            let trace_path = dir.join(format!("trace-{}.json", pid));
154            let (layer, guard) = tracing_chrome::ChromeLayerBuilder::new()
155                .file(trace_path)
156                .include_args(true)
157                .build();
158            (Some(layer), guard)
159        };
160        #[cfg(not(feature = "profiling"))]
161        let chrome_layer: Option<tracing_subscriber::layer::Identity> = None;
162
163        // --- Flamegraph layer (--features flamegraph) ---
164        #[cfg(feature = "flamegraph")]
165        let (flame_layer, flame_guard) = {
166            let flame_path = dir.join(format!("flame-{}.folded", pid));
167            let file =
168                std::fs::File::create(&flame_path).expect("cannot create flamegraph output file");
169            let writer = std::io::BufWriter::new(file);
170            let (layer, guard) = tracing_flame::FlameLayer::with_writer(writer).build();
171            (Some(layer), guard)
172        };
173        #[cfg(not(feature = "flamegraph"))]
174        let flame_layer: Option<tracing_subscriber::layer::Identity> = None;
175
176        // --- Prometheus metrics (--features prometheus) ---
177        #[cfg(feature = "prometheus")]
178        {
179            // Spawn metrics HTTP server on :9090 in background
180            let builder = metrics_exporter_prometheus::PrometheusBuilder::new();
181            if let Err(e) = builder.with_http_listener(([127, 0, 0, 1], 9090)).install() {
182                eprintln!("zshrs: failed to start prometheus exporter: {}", e);
183            }
184        }
185
186        // --- Assemble the subscriber registry ---
187        let subscriber = tracing_subscriber::registry()
188            .with(tracing_subscriber::EnvFilter::new(&env_filter))
189            .with(file_layer)
190            .with(chrome_layer)
191            .with(flame_layer);
192
193        let _ = tracing::subscriber::set_global_default(subscriber);
194
195        Guards {
196            #[cfg(feature = "profiling")]
197            _chrome: chrome_guard,
198            #[cfg(feature = "flamegraph")]
199            _flame: flame_guard,
200        }
201    });
202}
203
204/// Flush all log writers. Call before `std::process::exit()` to
205/// ensure buffered log data reaches disk — `exit()` doesn't run
206/// destructors.
207/// zshrs-original — no C counterpart. C zsh's stderr is line-
208/// buffered (or unbuffered), so it doesn't need an explicit flush
209/// step.
210pub fn flush() {
211    // The WorkerGuard flushes on drop, but we can't drop a static.
212    // Instead, give the non-blocking writer time to drain its buffer.
213    // 50ms is more than enough for any reasonable log volume.
214    std::thread::sleep(std::time::Duration::from_millis(50));
215}
216
217/// Check if the chrome-tracing profiling feature is compiled in.
218/// zshrs-original — no C counterpart. C zsh has the `zsh/zprof`
219/// module (Src/Modules/zprof.c) for function-level profiling but
220/// nothing as fine-grained as chrome:// tracing spans.
221pub fn profiling_enabled() -> bool {
222    cfg!(feature = "profiling")
223}
224
225/// Check if the flamegraph feature is compiled in.
226/// zshrs-original — no C counterpart.
227pub fn flamegraph_enabled() -> bool {
228    cfg!(feature = "flamegraph")
229}
230
231/// Check if the Prometheus metrics exporter is compiled in.
232/// zshrs-original — no C counterpart.
233pub fn prometheus_enabled() -> bool {
234    cfg!(feature = "prometheus")
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use std::sync::Mutex;
241
242    // Serialize env-mutating tests to avoid races. Other suites may
243    // touch ZSHRS_HOME concurrently; this lock keeps us inside ours.
244    static ENV_LOCK: Mutex<()> = Mutex::new(());
245
246    fn with_zshrs_home<F: FnOnce()>(value: Option<&str>, f: F) {
247        let _g = ENV_LOCK.lock().unwrap();
248        let prev = std::env::var_os("ZSHRS_HOME");
249        match value {
250            Some(v) => std::env::set_var("ZSHRS_HOME", v),
251            None => std::env::remove_var("ZSHRS_HOME"),
252        }
253        f();
254        match prev {
255            Some(v) => std::env::set_var("ZSHRS_HOME", v),
256            None => std::env::remove_var("ZSHRS_HOME"),
257        }
258    }
259
260    #[test]
261    fn log_dir_honors_zshrs_home_env_var() {
262        with_zshrs_home(Some("/tmp/zshrs-test-home"), || {
263            assert_eq!(log_dir(), PathBuf::from("/tmp/zshrs-test-home"));
264        });
265    }
266
267    #[test]
268    fn log_path_appends_zshrs_log_filename() {
269        with_zshrs_home(Some("/tmp/zshrs-test-path"), || {
270            assert_eq!(log_path(), PathBuf::from("/tmp/zshrs-test-path/zshrs.log"));
271        });
272    }
273
274    #[test]
275    fn log_dir_falls_back_when_zshrs_home_unset() {
276        with_zshrs_home(None, || {
277            let dir = log_dir();
278            // Must end in `.zshrs` regardless of whether HOME is set
279            // (no HOME → /tmp/.zshrs).
280            assert_eq!(dir.file_name().and_then(|s| s.to_str()), Some(".zshrs"));
281        });
282    }
283
284    #[test]
285    fn feature_flag_helpers_match_cfg() {
286        // Each helper must agree with the underlying cfg!() macro —
287        // a regression here would mean someone special-cased the
288        // helper independent of the feature.
289        assert_eq!(profiling_enabled(), cfg!(feature = "profiling"));
290        assert_eq!(flamegraph_enabled(), cfg!(feature = "flamegraph"));
291        assert_eq!(prometheus_enabled(), cfg!(feature = "prometheus"));
292    }
293
294    // ========================================================
295    // log_dir — ZSHRS_HOME precedence and fallback
296    // ========================================================
297
298    #[test]
299    fn log_dir_returns_absolute_when_zshrs_home_absolute() {
300        with_zshrs_home(Some("/var/log/zshrs"), || {
301            let d = log_dir();
302            assert!(d.is_absolute(), "log_dir should be absolute: {:?}", d);
303            assert_eq!(d, PathBuf::from("/var/log/zshrs"));
304        });
305    }
306
307    #[test]
308    fn log_dir_honors_relative_zshrs_home_verbatim() {
309        // No magic normalization — whatever ZSHRS_HOME is, log_dir
310        // returns it (with the `.zshrs` suffix replaced).
311        with_zshrs_home(Some("relative/path"), || {
312            assert_eq!(log_dir(), PathBuf::from("relative/path"));
313        });
314    }
315
316    #[test]
317    fn log_dir_distinct_from_log_path() {
318        with_zshrs_home(Some("/tmp/zshrs-distinct"), || {
319            let d = log_dir();
320            let p = log_path();
321            assert_ne!(d, p, "dir and path must differ");
322            assert_eq!(p.parent(), Some(d.as_path()));
323        });
324    }
325
326    #[test]
327    fn log_path_filename_is_zshrs_dot_log() {
328        with_zshrs_home(Some("/tmp/zshrs-fname"), || {
329            assert_eq!(
330                log_path().file_name().and_then(|s| s.to_str()),
331                Some("zshrs.log")
332            );
333        });
334    }
335
336    #[test]
337    fn log_dir_overrides_persist_for_repeated_calls() {
338        // Calling twice in sequence should return identical values
339        // — confirms there is no hidden caching that pins the first
340        // call's result.
341        with_zshrs_home(Some("/tmp/zshrs-cache-1"), || {
342            assert_eq!(log_dir(), PathBuf::from("/tmp/zshrs-cache-1"));
343        });
344        with_zshrs_home(Some("/tmp/zshrs-cache-2"), || {
345            assert_eq!(log_dir(), PathBuf::from("/tmp/zshrs-cache-2"));
346        });
347    }
348
349    #[test]
350    fn log_dir_empty_zshrs_home_is_taken_verbatim() {
351        // env::var_os returns Some("") for empty — code path uses
352        // PathBuf::from("") which still gets returned. Verify no
353        // panic / silent fallback.
354        with_zshrs_home(Some(""), || {
355            assert_eq!(log_dir(), PathBuf::from(""));
356        });
357    }
358
359    #[test]
360    fn log_dir_fallback_path_ends_with_dot_zshrs_filename_component() {
361        with_zshrs_home(None, || {
362            let d = log_dir();
363            assert_eq!(d.file_name().and_then(|s| s.to_str()), Some(".zshrs"));
364        });
365    }
366
367    #[test]
368    fn log_path_inside_zshrs_home_directory() {
369        with_zshrs_home(Some("/tmp/zshrs-inside"), || {
370            let p = log_path();
371            assert!(
372                p.starts_with("/tmp/zshrs-inside"),
373                "log_path should live inside ZSHRS_HOME: {:?}",
374                p
375            );
376        });
377    }
378
379    // ========================================================
380    // Feature-flag helpers — interaction matrix
381    // ========================================================
382
383    #[test]
384    fn feature_helpers_are_stable_within_one_call() {
385        // Two back-to-back calls produce identical output (no random
386        // toggling). Trivial sanity check, catches any future
387        // accidental side-effect inside the helpers.
388        assert_eq!(profiling_enabled(), profiling_enabled());
389        assert_eq!(flamegraph_enabled(), flamegraph_enabled());
390        assert_eq!(prometheus_enabled(), prometheus_enabled());
391    }
392
393    #[test]
394    fn flush_is_idempotent_and_returns_unit() {
395        // `flush` sleeps ~50ms but must always return cleanly. Two
396        // back-to-back calls should still be safe.
397        flush();
398        flush();
399    }
400}