Skip to main content

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), &current_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, &current).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}