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}