agentmux_srv/bootstrap/logging.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Split out of the single-file bootstrap module unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.3).
5
6/// Route `log` crate records into `tracing`, at info and above.
7///
8/// The subscriber is built by hand (`registry()...set_global_default`), which does
9/// not install the `log` -> `tracing` bridge that `.init()` would. So every record
10/// from a dependency that logs through the `log` crate was dropped, at every level:
11/// `mdns-sd` included, whose `error!("bind a socket to {}: {}. Skipped.")` is exactly
12/// what was missing when an instance could hear its peers and not be heard
13/// (Area54, 2026-10-02), and which cost several cloud round trips to find by hand.
14///
15/// The cap is `Info`: the `log` macros skip anything above it before formatting, so
16/// the chatty `debug!`/`trace!` records of `mdns-sd`, `hyper` and friends cost
17/// nothing, and the `EnvFilter` below still decides what is written. Returns whether
18/// the bridge was installed (it is not if another `log` logger already is).
19pub(crate) fn install_log_bridge() -> bool {
20 tracing_log::LogTracer::builder().with_max_level(log::LevelFilter::Info).init().is_ok()
21}
22
23/// Initialize tracing with dual output: JSON rolling file + human-readable stderr.
24/// Returns a guard that must be held for the lifetime of the app to ensure log flushing.
25pub fn init_logging() -> tracing_appender::non_blocking::WorkerGuard {
26 use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter};
27
28 // Prefer AGENTMUX_LOG_DIR (set by the launcher, per-instance-scoped —
29 // agentmux-common::DataPaths::to_env_vars) so srv's log file lands in the
30 // SAME per-channel directory agentmux-cef's host log already uses, and
31 // the same directory the shell-integration pointer lookups
32 // (bash.sh/zsh.sh/fish.fish/pwsh.ps1's `$AGENTMUX_LOG_DIR/current-<target>-
33 // v<version>.path`) already assume both host AND srv write into. Before
34 // this, srv unconditionally wrote to the SHARED ~/.agentmux/logs/ root
35 // regardless of channel — every instance at the same version interleaved
36 // into one file, with no way to attribute a line back to its instance
37 // (see docs/reports/REPORT_MUXSPECT_MUXLOG_CROSS_CHANNEL_INSPECTION_2026_08_22.md
38 // §2.2-2.3). Read inline (not via DataPaths::from_env(), which requires
39 // several other vars to all be present) to match this file's existing
40 // lightweight-env-var-read style for a single value (see the
41 // AGENTMUX_CHANNEL/AGENTMUX_HOME_OVERRIDE reads below) and to keep
42 // working the same as before when AGENTMUX_LOG_DIR isn't set (a
43 // standalone/no-launcher run).
44 // Version is embedded in the filename for side-by-side coexistence.
45 let version = env!("CARGO_PKG_VERSION");
46 let log_dir = std::env::var_os("AGENTMUX_LOG_DIR")
47 .filter(|s| !s.is_empty())
48 .map(std::path::PathBuf::from)
49 .unwrap_or_else(|| {
50 dirs::home_dir()
51 .unwrap_or_default()
52 .join(".agentmux")
53 .join("logs")
54 });
55 let _ = std::fs::create_dir_all(&log_dir);
56
57 // Delete log files older than 7 days to prevent unbounded growth.
58 agentmux_common::log_retention::cleanup_old_logs(&log_dir, 7);
59
60 // Rolling daily log file with JSON structured output
61 let log_prefix = format!("agentmuxsrv-v{}.log", version);
62 let file_appender = tracing_appender::rolling::daily(&log_dir, &log_prefix);
63 let (non_blocking_file, guard) = tracing_appender::non_blocking(file_appender);
64
65 // Write pointer to current log file for zero-lookup agent discovery.
66 // Version-qualified name so multi-instance doesn't clobber pointers.
67 let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
68 let current_filename = format!("{}.{}", log_prefix, today);
69 let pointer_name = format!("current-srv-v{}.path", version);
70 let _ = std::fs::write(log_dir.join(&pointer_name), ¤t_filename);
71
72 // Spawn a background thread to refresh the pointer on UTC date rollover.
73 // tracing_appender::rolling::daily creates a new file at midnight UTC.
74 {
75 let log_dir = log_dir.clone();
76 let log_prefix = log_prefix.clone();
77 let pointer_name = pointer_name.clone();
78 std::thread::Builder::new()
79 .name("srv-log-pointer".into())
80 .spawn(move || {
81 let mut last_date = chrono::Utc::now().format("%Y-%m-%d").to_string();
82 loop {
83 std::thread::sleep(std::time::Duration::from_secs(60));
84 let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
85 if last_date != today {
86 last_date = today.clone();
87 let filename = format!("{}.{}", log_prefix, today);
88 let _ = std::fs::write(log_dir.join(&pointer_name), &filename);
89 }
90 }
91 })
92 .ok();
93 }
94
95 let subscriber = tracing_subscriber::registry()
96 .with(
97 EnvFilter::try_from_default_env()
98 .unwrap_or_else(|_| EnvFilter::new("agentmuxsrv=info,info")),
99 )
100 .with(
101 fmt::layer()
102 .json()
103 .with_writer(non_blocking_file)
104 .with_target(true)
105 .with_thread_ids(true),
106 )
107 .with(
108 fmt::layer()
109 .with_writer(std::io::stderr)
110 .with_ansi(true),
111 );
112
113 install_log_bridge();
114 tracing::subscriber::set_global_default(subscriber).ok();
115
116 tracing::info!(
117 version = env!("CARGO_PKG_VERSION"),
118 os = std::env::consts::OS,
119 arch = std::env::consts::ARCH,
120 log_dir = %log_dir.display(),
121 "agentmuxsrv starting"
122 );
123
124 guard
125}
126
127/// How long after boot the cached login-shell PATH is re-checked. Long enough
128/// that the shell spawn (1-2 s of CPU with oh-my-zsh/conda/nvm) doesn't compete
129/// with the window loading.
130#[cfg(unix)]
131const LOGIN_PATH_REFRESH_DELAY: std::time::Duration = std::time::Duration::from_secs(15);
132
133/// Step 1b: PATH enrichment for a GUI launch. Every Finder/Dock launch gets
134/// launchd's stripped PATH (the launcher spawns srv with it), so this runs on
135/// every macOS launch, not only on dev paths, and it runs before ESTART: the
136/// window can't start until it is done. An interactive login shell costs 1-2 s,
137/// so the result is cached in the shared dir and reused while the shell's
138/// startup files are unchanged, then re-checked in the background. See
139/// SPEC_TOOLCHAIN_MANAGER_2026-06-15 §3.1.
140pub fn enrich_path() {
141 let inherited = std::env::var("PATH").unwrap_or_default();
142 let cache_file = std::env::var_os("AGENTMUX_SHARED_DIR")
143 .map(|d| std::path::PathBuf::from(d).join("cache").join("login-path.json"));
144 let outcome = agentmux_common::enrich_current_process_path_cached(cache_file.as_deref());
145 if outcome.source != agentmux_common::PathSource::Inherited {
146 // Record the source for the Toolchain modal (the host sets this when it
147 // spawns the srv; on a direct launch we set it here after enriching).
148 std::env::set_var("AGENTMUX_PATH_SOURCE", outcome.source.as_str());
149 tracing::info!(
150 source = outcome.source.as_str(),
151 cached = outcome.from_cache,
152 "Enriched srv PATH on direct launch (stripped PATH detected)"
153 );
154 }
155
156 // Refresh after every cache hit, including one that added nothing: a
157 // cached "nothing to add" must still be able to notice a tool directory
158 // the user's setup gains later through a file the fingerprint can't see.
159
160 #[cfg(unix)]
161 if let (true, Some(cache_file)) = (outcome.from_cache, cache_file) {
162 std::thread::spawn(move || {
163 std::thread::sleep(LOGIN_PATH_REFRESH_DELAY);
164 let current = std::env::var("PATH").unwrap_or_default();
165 if agentmux_common::refresh_login_path_cache(&cache_file, &inherited, ¤t).is_some() {
166 // A file the fingerprint can't see changed (a sourced plugin,
167 // nvm's default alias). The cache now holds the fresh value for
168 // the next launch. It is deliberately not applied to this
169 // process: other threads are running by now, and `set_var`
170 // can race C code reading the environment (libc `getenv`).
171 tracing::info!(
172 "login-shell PATH changed since the cached copy; the next launch uses the fresh one"
173 );
174 }
175 });
176 }
177 #[cfg(not(unix))]
178 let _ = inherited;
179}
180
181#[cfg(test)]
182mod tests {
183 use std::io::Write;
184 use std::sync::{Arc, Mutex};
185
186 #[derive(Clone, Default)]
187 struct Capture(Arc<Mutex<Vec<u8>>>);
188
189 impl Write for Capture {
190 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
191 self.0.lock().unwrap().extend_from_slice(buf);
192 Ok(buf.len())
193 }
194 fn flush(&mut self) -> std::io::Result<()> {
195 Ok(())
196 }
197 }
198
199 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Capture {
200 type Writer = Capture;
201 fn make_writer(&'a self) -> Capture {
202 self.clone()
203 }
204 }
205
206 /// The failure this exists for: a `log::error!` from `mdns-sd` must reach the srv
207 /// log. Before the bridge it was dropped without a trace.
208 #[test]
209 fn a_log_crate_error_from_mdns_sd_reaches_tracing_but_debug_noise_does_not() {
210 if !super::install_log_bridge() {
211 // Another `log` logger is already installed in this test process, so the
212 // bridge cannot be exercised here; production has none before this runs.
213 eprintln!("skipped: a log logger is already installed in this process");
214 return;
215 }
216 let out = Capture::default();
217 let subscriber = tracing_subscriber::fmt().with_writer(out.clone()).with_max_level(tracing::Level::INFO).finish();
218 tracing::subscriber::with_default(subscriber, || {
219 log::error!(target: "mdns_sd::service_daemon", "bind a socket to 192.168.1.26: socket bind to 0.0.0.0:5353 failed. Skipped.");
220 log::debug!(target: "mdns_sd::service_daemon", "send outgoing query: 1 questions");
221 });
222 let text = String::from_utf8(out.0.lock().unwrap().clone()).unwrap();
223 assert!(text.contains("bind a socket to 192.168.1.26"), "the error must be logged: {text:?}");
224 assert!(text.contains("mdns_sd"), "and attributed to its crate: {text:?}");
225 assert!(!text.contains("send outgoing query"), "debug stays out (the bridge caps at info): {text:?}");
226 }
227}