Skip to main content

ssh_cli/
telemetry.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! Process-local **tracing** setup for the ssh-cli binary path.
5//!
6//! # Product identity (Rules Rust — logs / tracing / rotation)
7//!
8//! ssh-cli is a **one-shot agent CLI**, not a long-lived server:
9//!
10//! | Signal | Policy |
11//! |--------|--------|
12//! | Facade | `tracing` only (no `println!` / `env_logger` / dual `log` consumer) |
13//! | Sink | **stderr** text (data JSON stays on **stdout**) |
14//! | Default filter | `error` (agent-first quiet stderr) |
15//! | Override | **CLI only:** `-v` → `debug`; ambient `RUST_LOG` is **ignored**; `-q` does not change the filter |
16//! | Reload | `reload::Layer` so bootstrap (pre-parse) can reconfigure after argv |
17//! | Bridge | `tracing-log::LogTracer` for deps that emit via the `log` crate (`russh`, `keyring`) |
18//! | Errors | `tracing_error::ErrorLayer` for `SpanTrace` capture |
19//!
20//! # Explicitly out of scope
21//!
22//! - OpenTelemetry / OTLP / metrics backends (product: **zero telemetry**)
23//! - `tracing-appender` file rotation + `WorkerGuard` (no local log files; short process)
24//! - Admin HTTP `/admin/log-level` (no daemon / no network control plane)
25//! - `tokio-console` / Chrome tracing / journald / Docker log drivers as product features
26//! - Encrypted log-at-rest (no log files written by this binary)
27//!
28//! Libraries that depend on `ssh_cli` as a crate should **not** call these
29//! installers; only the binary entry (`run` → `bootstrap_logs`) installs the
30//! global subscriber. Product modules only emit events/spans.
31//!
32//! # Lifecycle
33//!
34//! 1. [`bootstrap_logs`] — before clap parse (phase 1b).
35//! 2. [`initialize_logs`] — after parse, reloads `EnvFilter` from `-v` only (G-E2E-09).
36//! 3. Process exit — `main` flushes stderr; no file worker to join.
37
38use std::sync::OnceLock;
39
40use tracing_error::ErrorLayer;
41use tracing_subscriber::reload;
42use tracing_subscriber::{fmt, prelude::*, EnvFilter, Registry};
43
44/// Reload handle: bootstrap installs once; [`initialize_logs`] reloads the filter.
45///
46/// `OnceLock` (not `lazy_static`): value is created only when the binary path
47/// installs the global subscriber.
48static LOG_FILTER_RELOAD: OnceLock<reload::Handle<EnvFilter, Registry>> = OnceLock::new();
49
50/// Builds the process-local tracing filter from CLI `-v` count only (G-AUD-22 / G14).
51///
52/// | level | filter |
53/// |-------|--------|
54/// | 0     | `error` |
55/// | 1 (`-v`) | `warn,ssh_cli=info` |
56/// | 2 (`-vv`) | `warn,ssh_cli=debug` |
57/// | ≥3 (`-vvv`) | `warn,ssh_cli=trace` |
58///
59/// Ambient `RUST_LOG` is **ignored** (not an env store of product config).
60/// `--quiet` affects human stdout only ([`crate::output::set_quiet`]).
61///
62/// **G2 security:** never emit a bare global `debug`/`trace` directive — that
63/// enables `russh::client::encrypted`, which logs the raw userauth packet
64/// (SSH password in cleartext) to stderr.
65#[must_use]
66pub fn build_env_filter(verbose: u8) -> EnvFilter {
67    match verbose {
68        0 => EnvFilter::new("error"),
69        1 => EnvFilter::new("warn,ssh_cli=info"),
70        2 => EnvFilter::new("warn,ssh_cli=debug"),
71        _ => EnvFilter::new("warn,ssh_cli=trace"),
72    }
73}
74
75/// Installs stderr tracing **before** clap parse (one-shot lifecycle phase 1b).
76///
77/// Default filter is `error` so agents stay quiet; [`initialize_logs`] reloads
78/// from `-v` after parse (ambient `RUST_LOG` ignored).
79///
80/// Safe to call more than once: subsequent calls are no-ops once the reload
81/// handle is stored (or if another test subscriber already owns the global).
82pub fn bootstrap_logs() {
83    if LOG_FILTER_RELOAD.get().is_some() {
84        return;
85    }
86
87    let (filter_layer, handle) = reload::Layer::new(EnvFilter::new("error"));
88    let subscriber = Registry::default()
89        .with(filter_layer)
90        .with(ErrorLayer::default())
91        .with(
92            fmt::layer()
93                .with_writer(std::io::stderr)
94                // Targets remain in the log line for human diagnostics.
95                .with_target(true)
96                // Tokio workers are named `ssh-cli-worker` in `main`.
97                .with_thread_names(true)
98                // Agents / CI: never emit ANSI on the diagnostics channel.
99                .with_ansi(false),
100        );
101
102    // Prefer `set_global_default` so the reload handle stays valid.
103    // Ignore failure when tests already installed a subscriber.
104    if tracing::subscriber::set_global_default(subscriber).is_ok() {
105        let _ = LOG_FILTER_RELOAD.set(handle);
106        // Bridge `log` crate records (russh, keyring, …) into `tracing`.
107        // Ignore if already installed (re-entrant tests).
108        let _ = tracing_log::LogTracer::builder()
109            .with_max_level(log::LevelFilter::Trace)
110            .init();
111        tracing::debug!("tracing subscriber installed (stderr, filter=error)");
112    }
113}
114
115/// Initializes or reloads `tracing-subscriber` from the verbose CLI count (G14).
116///
117/// GAP-SSH-LOG-001 / G-AUD-22: default **error** (agent-first).
118/// Ambient `RUST_LOG` is ignored (CLI-only filter).
119pub fn initialize_logs(verbose: u8) {
120    let filter = build_env_filter(verbose);
121    if let Some(handle) = LOG_FILTER_RELOAD.get() {
122        match handle.reload(filter) {
123            Ok(()) => {
124                // Visible only when the new filter admits `debug` (e.g. `-vv`).
125                tracing::debug!(
126                    verbose,
127                    rust_log_set = std::env::var_os("RUST_LOG").is_some(),
128                    "tracing filter reloaded"
129                );
130            }
131            Err(e) => {
132                // Keep the previous filter; surface the reload failure.
133                tracing::warn!(err = %e, "failed to reload tracing filter");
134            }
135        }
136        return;
137    }
138
139    // Tests / alternate entry: no bootstrap — try_init once (no reload handle).
140    let _ = fmt()
141        .with_env_filter(filter)
142        .with_writer(std::io::stderr)
143        .with_target(true)
144        .with_thread_names(true)
145        .with_ansi(false)
146        .try_init();
147    let _ = tracing_log::LogTracer::builder()
148        .with_max_level(log::LevelFilter::Trace)
149        .init();
150}
151
152/// Returns whether the process owns a reloadable global filter (binary path).
153#[must_use]
154pub fn has_reload_handle() -> bool {
155    LOG_FILTER_RELOAD.get().is_some()
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn build_env_filter_default_is_error() {
164        // Isolate from ambient RUST_LOG in the test runner.
165        let prev = std::env::var_os("RUST_LOG");
166        crate::test_util::env::remove_var("RUST_LOG");
167        let f = build_env_filter(0);
168        assert_eq!(f.to_string(), "error");
169        match prev {
170            Some(v) => crate::test_util::env::set_var("RUST_LOG", v),
171            None => crate::test_util::env::remove_var("RUST_LOG"),
172        }
173    }
174
175    #[test]
176    fn build_env_filter_verbose_scopes_levels_to_this_crate() {
177        let prev = std::env::var_os("RUST_LOG");
178        crate::test_util::env::remove_var("RUST_LOG");
179        let f1 = build_env_filter(1).to_string();
180        let f2 = build_env_filter(2).to_string();
181        let f3 = build_env_filter(3).to_string();
182        assert!(
183            f1.contains("ssh_cli=info"),
184            "-v must enable info for the product crate, got {f1:?}"
185        );
186        assert!(
187            f2.contains("ssh_cli=debug"),
188            "-vv must enable debug for the product crate, got {f2:?}"
189        );
190        assert!(
191            f3.contains("ssh_cli=trace"),
192            "-vvv must enable trace for the product crate, got {f3:?}"
193        );
194        // G2 security: never a bare global debug/trace directive.
195        for f in [&f1, &f2, &f3] {
196            assert!(
197                !f.split(',')
198                    .any(|d| d.trim() == "debug" || d.trim() == "trace" || d.trim() == "info"),
199                "verbose must NOT set a global level directive, got {f:?}"
200            );
201        }
202        match prev {
203            Some(v) => crate::test_util::env::set_var("RUST_LOG", v),
204            None => crate::test_util::env::remove_var("RUST_LOG"),
205        }
206    }
207
208    #[test]
209    fn bootstrap_logs_is_idempotent() {
210        bootstrap_logs();
211        bootstrap_logs();
212        // In a full binary path the handle is set; under parallel tests another
213        // suite may have taken the global subscriber first — both outcomes OK.
214        let _ = has_reload_handle();
215    }
216}