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 `--verbose` only (G-AUD-22).
51///
52/// - `verbose == true` (`-v`) → `debug`
53/// - otherwise → `error`
54///
55/// Ambient `RUST_LOG` is **ignored** (not an env store of product config).
56/// `--quiet` affects human stdout only ([`crate::output::set_quiet`]).
57#[must_use]
58pub fn build_env_filter(verbose: bool) -> EnvFilter {
59    if verbose {
60        return EnvFilter::new("debug");
61    }
62    EnvFilter::new("error")
63}
64
65/// Installs stderr tracing **before** clap parse (one-shot lifecycle phase 1b).
66///
67/// Default filter is `error` so agents stay quiet; [`initialize_logs`] reloads
68/// from `-v` after parse (ambient `RUST_LOG` ignored).
69///
70/// Safe to call more than once: subsequent calls are no-ops once the reload
71/// handle is stored (or if another test subscriber already owns the global).
72pub fn bootstrap_logs() {
73    if LOG_FILTER_RELOAD.get().is_some() {
74        return;
75    }
76
77    let (filter_layer, handle) = reload::Layer::new(EnvFilter::new("error"));
78    let subscriber = Registry::default()
79        .with(filter_layer)
80        .with(ErrorLayer::default())
81        .with(
82            fmt::layer()
83                .with_writer(std::io::stderr)
84                // Targets remain in the log line for human diagnostics.
85                .with_target(true)
86                // Tokio workers are named `ssh-cli-worker` in `main`.
87                .with_thread_names(true)
88                // Agents / CI: never emit ANSI on the diagnostics channel.
89                .with_ansi(false),
90        );
91
92    // Prefer `set_global_default` so the reload handle stays valid.
93    // Ignore failure when tests already installed a subscriber.
94    if tracing::subscriber::set_global_default(subscriber).is_ok() {
95        let _ = LOG_FILTER_RELOAD.set(handle);
96        // Bridge `log` crate records (russh, keyring, …) into `tracing`.
97        // Ignore if already installed (re-entrant tests).
98        let _ = tracing_log::LogTracer::builder()
99            .with_max_level(log::LevelFilter::Trace)
100            .init();
101        tracing::debug!("tracing subscriber installed (stderr, filter=error)");
102    }
103}
104
105/// Initializes or reloads `tracing-subscriber` from the verbose CLI flag.
106///
107/// GAP-SSH-LOG-001 / G-AUD-22: default **error** (agent-first). `-v` → debug.
108/// Ambient `RUST_LOG` is ignored (CLI-only filter).
109pub fn initialize_logs(verbose: bool) {
110    let filter = build_env_filter(verbose);
111    if let Some(handle) = LOG_FILTER_RELOAD.get() {
112        match handle.reload(filter) {
113            Ok(()) => {
114                // Visible only when the new filter admits `debug` (e.g. `-v`).
115                tracing::debug!(
116                    verbose,
117                    rust_log_set = std::env::var_os("RUST_LOG").is_some(),
118                    "tracing filter reloaded"
119                );
120            }
121            Err(e) => {
122                // Keep the previous filter; surface the reload failure.
123                tracing::warn!(err = %e, "failed to reload tracing filter");
124            }
125        }
126        return;
127    }
128
129    // Tests / alternate entry: no bootstrap — try_init once (no reload handle).
130    let _ = fmt()
131        .with_env_filter(filter)
132        .with_writer(std::io::stderr)
133        .with_target(true)
134        .with_thread_names(true)
135        .with_ansi(false)
136        .try_init();
137    let _ = tracing_log::LogTracer::builder()
138        .with_max_level(log::LevelFilter::Trace)
139        .init();
140}
141
142/// Returns whether the process owns a reloadable global filter (binary path).
143#[must_use]
144pub fn has_reload_handle() -> bool {
145    LOG_FILTER_RELOAD.get().is_some()
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn build_env_filter_default_is_error() {
154        // Isolate from ambient RUST_LOG in the test runner.
155        let prev = std::env::var_os("RUST_LOG");
156        crate::test_util::env::remove_var("RUST_LOG");
157        let f = build_env_filter(false);
158        assert_eq!(f.to_string(), "error");
159        match prev {
160            Some(v) => crate::test_util::env::set_var("RUST_LOG", v),
161            None => crate::test_util::env::remove_var("RUST_LOG"),
162        }
163    }
164
165    #[test]
166    fn build_env_filter_verbose_is_debug() {
167        let prev = std::env::var_os("RUST_LOG");
168        crate::test_util::env::remove_var("RUST_LOG");
169        let f = build_env_filter(true);
170        assert_eq!(f.to_string(), "debug");
171        match prev {
172            Some(v) => crate::test_util::env::set_var("RUST_LOG", v),
173            None => crate::test_util::env::remove_var("RUST_LOG"),
174        }
175    }
176
177    #[test]
178    fn bootstrap_logs_is_idempotent() {
179        bootstrap_logs();
180        bootstrap_logs();
181        // In a full binary path the handle is set; under parallel tests another
182        // suite may have taken the global subscriber first — both outcomes OK.
183        let _ = has_reload_handle();
184    }
185}