Skip to main content

seer_core/
logging.rs

1//! Arcanum suite logging initialisation.
2//!
3//! Reads `ARCANUM_LOG_LEVEL`, `ARCANUM_LOG_FORMAT`, `ARCANUM_LOG_DIR`,
4//! `ARCANUM_LOG_FILE`, and `ARCANUM_OTEL_ENDPOINT` and installs a global
5//! tracing subscriber.
6//!
7//! # Usage
8//!
9//! ```rust,no_run
10//! let _guard = seer_core::logging::init_logging_with_writer("seer", "error", std::io::stderr);
11//! ```
12//!
13//! The returned guard **must** be kept alive for the lifetime of the process
14//! so that the file appender can flush on exit.
15
16use std::path::PathBuf;
17use std::sync::OnceLock;
18
19use tracing_subscriber::{
20    fmt::{self, MakeWriter},
21    layer::SubscriberExt,
22    util::SubscriberInitExt,
23    EnvFilter, Layer,
24};
25
26static INITIALIZED: OnceLock<()> = OnceLock::new();
27
28/// Guard returned by [`init_logging_with_writer`].
29///
30/// Holds the file appender worker guard (if file logging is enabled).
31/// Drop this only when the process is about to exit.
32pub struct LogGuard {
33    _file_guard: Option<tracing_appender::non_blocking::WorkerGuard>,
34}
35
36/// Initialise the global tracing subscriber with a console writer
37/// (`std::io::stderr`, or a progress-bar-aware writer as `seer-cli` uses).
38///
39/// `default_level` is used when neither `ARCANUM_LOG_LEVEL` nor `RUST_LOG`
40/// is set. Typical values: `"error"` for CLIs, `"info"` for servers.
41pub fn init_logging_with_writer<W>(app_name: &str, default_level: &str, writer: W) -> LogGuard
42where
43    W: for<'a> MakeWriter<'a> + Send + Sync + 'static,
44{
45    // Guard against double-init (e.g. test harnesses).
46    if INITIALIZED.set(()).is_err() {
47        return LogGuard { _file_guard: None };
48    }
49
50    let env_filter = build_env_filter(default_level);
51    let log_format = read_env("ARCANUM_LOG_FORMAT", "text");
52    let file_enabled = matches!(
53        read_env("ARCANUM_LOG_FILE", "").to_lowercase().as_str(),
54        "1" | "true" | "yes"
55    );
56
57    let json_mode = log_format == "json";
58
59    // Build file appender layer if enabled
60    // `tracing_appender::rolling::daily` panics ("initializing rolling file
61    // appender failed") when the directory can't be created or written, which
62    // took down every invocation — even `--help` — on a read-only home or an
63    // unwritable ARCANUM_LOG_DIR. The builder reports that as an error
64    // instead: warn once and carry on with console logging only.
65    let file_appender = if file_enabled {
66        let dir = log_dir();
67        ensure_log_dir(&dir);
68        match tracing_appender::rolling::RollingFileAppender::builder()
69            .rotation(tracing_appender::rolling::Rotation::DAILY)
70            .filename_prefix(format!("{app_name}.log"))
71            .build(&dir)
72        {
73            Ok(appender) => Some(appender),
74            Err(e) => {
75                eprintln!(
76                    "warning: file logging disabled — cannot write to {}: {e}",
77                    dir.display()
78                );
79                None
80            }
81        }
82    } else {
83        None
84    };
85
86    // One boxed layer per sink, in JSON or text as configured.
87    let (file_layer, file_guard) = match file_appender {
88        Some(file_appender) => {
89            let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
90            let layer = if json_mode {
91                fmt::layer().json().with_writer(non_blocking).boxed()
92            } else {
93                fmt::layer().with_writer(non_blocking).boxed()
94            };
95            (Some(layer), Some(guard))
96        }
97        None => (None, None),
98    };
99
100    let console_layer = if json_mode {
101        fmt::layer().json().with_writer(writer).boxed()
102    } else {
103        fmt::layer().with_writer(writer).boxed()
104    };
105
106    // Build optional OpenTelemetry OTLP layer (boxed for type erasure).
107    // Compiled out entirely when the `otel` feature is disabled.
108    #[cfg(feature = "otel")]
109    let otel_layer = build_otel_layer(app_name).map(|l| l.boxed());
110
111    // Use try_init() — if another subscriber is already registered (e.g.,
112    // when both seer-core and tome-core are linked into the same process),
113    // this silently succeeds without panicking.
114    let registry = tracing_subscriber::registry()
115        .with(env_filter)
116        .with(console_layer)
117        .with(file_layer);
118
119    #[cfg(feature = "otel")]
120    let registry = registry.with(otel_layer);
121
122    let _ = registry.try_init();
123
124    // Silence unused-variable warning when `otel` is disabled — the layer
125    // builder still consumes `app_name` in the feature-on path.
126    #[cfg(not(feature = "otel"))]
127    let _ = app_name;
128
129    LogGuard {
130        _file_guard: file_guard,
131    }
132}
133
134/// Returns the resolved log directory.
135///
136/// Reads `ARCANUM_LOG_DIR`, falls back to `~/.arcanum/logs/`.
137pub fn log_dir() -> PathBuf {
138    if let Ok(dir) = std::env::var("ARCANUM_LOG_DIR") {
139        return PathBuf::from(dir);
140    }
141    std::env::home_dir()
142        .unwrap_or_else(|| PathBuf::from("."))
143        .join(".arcanum")
144        .join("logs")
145}
146
147/// Creates the log directory (best-effort) and restricts it to owner-only
148/// (0700) on Unix. Log files carry internal-IP diagnostic detail that is
149/// deliberately kept out of client-facing errors (e.g. the SSRF guard's
150/// resolved-IP debug lines), so the directory gets the same owner-only
151/// treatment as `~/.seer` (see history.rs / watchlist.rs). Non-unix keeps
152/// platform-default permissions.
153fn ensure_log_dir(dir: &std::path::Path) {
154    let _ = std::fs::create_dir_all(dir);
155    #[cfg(unix)]
156    {
157        use std::os::unix::fs::PermissionsExt;
158        // Also tightens a pre-existing directory created by older versions
159        // with default permissions.
160        let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
161    }
162}
163
164// ---- internal helpers ----
165
166fn build_env_filter(default_level: &str) -> EnvFilter {
167    let level = read_env_chain(&["ARCANUM_LOG_LEVEL", "RUST_LOG"], default_level);
168    EnvFilter::try_new(&level).unwrap_or_else(|_| EnvFilter::new(default_level))
169}
170
171fn read_env(key: &str, default: &str) -> String {
172    std::env::var(key).unwrap_or_else(|_| default.to_string())
173}
174
175fn read_env_chain(keys: &[&str], default: &str) -> String {
176    for key in keys {
177        if let Ok(val) = std::env::var(key) {
178            if !val.is_empty() {
179                return val;
180            }
181        }
182    }
183    default.to_string()
184}
185
186/// Build the OpenTelemetry OTLP tracing layer if `ARCANUM_OTEL_ENDPOINT` is
187/// set. Returns `None` (zero cost) when the env var is absent. Only compiled
188/// when the `otel` feature is enabled.
189#[cfg(feature = "otel")]
190fn build_otel_layer<S>(
191    service_name: &str,
192) -> Option<tracing_opentelemetry::OpenTelemetryLayer<S, opentelemetry_sdk::trace::SdkTracer>>
193where
194    S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
195{
196    use opentelemetry::trace::TracerProvider as _;
197    use opentelemetry_otlp::WithExportConfig as _;
198
199    let endpoint = std::env::var("ARCANUM_OTEL_ENDPOINT").ok()?;
200    if endpoint.is_empty() {
201        return None;
202    }
203
204    // Build the OTLP exporter → tracer → layer.
205    let exporter = opentelemetry_otlp::SpanExporter::builder()
206        .with_tonic()
207        .with_endpoint(&endpoint)
208        .build()
209        .ok()?;
210
211    let tracer_provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
212        .with_batch_exporter(exporter)
213        .with_resource(
214            opentelemetry_sdk::Resource::builder()
215                .with_service_name(service_name.to_string())
216                .build(),
217        )
218        .build();
219
220    let tracer = tracer_provider.tracer(service_name.to_string());
221
222    // Keep the provider alive — leaking is acceptable here because it lives
223    // for the process lifetime and must not be dropped before shutdown.
224    std::mem::forget(tracer_provider);
225
226    Some(tracing_opentelemetry::layer().with_tracer(tracer))
227}
228
229#[cfg(test)]
230mod tests {
231    use super::build_env_filter;
232
233    // Serialize env-var tests so parallel runs don't collide on the shared
234    // process env.
235    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
236
237    fn with_clean_env<F: FnOnce() -> R, R>(f: F) -> R {
238        let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
239        let prev_arcanum = std::env::var("ARCANUM_LOG_LEVEL").ok();
240        let prev_rust = std::env::var("RUST_LOG").ok();
241        std::env::remove_var("ARCANUM_LOG_LEVEL");
242        std::env::remove_var("RUST_LOG");
243        let result = f();
244        match prev_arcanum {
245            Some(v) => std::env::set_var("ARCANUM_LOG_LEVEL", v),
246            None => std::env::remove_var("ARCANUM_LOG_LEVEL"),
247        }
248        match prev_rust {
249            Some(v) => std::env::set_var("RUST_LOG", v),
250            None => std::env::remove_var("RUST_LOG"),
251        }
252        result
253    }
254
255    #[test]
256    fn test_default_level_used_when_no_env_set() {
257        with_clean_env(|| {
258            let filter = build_env_filter("error");
259            // EnvFilter's Display renders the directive set.
260            assert_eq!(format!("{}", filter), "error");
261        });
262    }
263
264    #[test]
265    fn test_arcanum_log_level_overrides_default() {
266        with_clean_env(|| {
267            std::env::set_var("ARCANUM_LOG_LEVEL", "debug");
268            let filter = build_env_filter("error");
269            assert_eq!(format!("{}", filter), "debug");
270        });
271    }
272
273    #[test]
274    fn test_rust_log_overrides_default() {
275        with_clean_env(|| {
276            std::env::set_var("RUST_LOG", "info");
277            let filter = build_env_filter("error");
278            assert_eq!(format!("{}", filter), "info");
279        });
280    }
281
282    #[test]
283    fn test_arcanum_log_level_takes_precedence_over_rust_log() {
284        with_clean_env(|| {
285            std::env::set_var("ARCANUM_LOG_LEVEL", "warn");
286            std::env::set_var("RUST_LOG", "trace");
287            let filter = build_env_filter("error");
288            assert_eq!(format!("{}", filter), "warn");
289        });
290    }
291
292    #[cfg(unix)]
293    #[test]
294    #[allow(clippy::unwrap_used)]
295    fn test_ensure_log_dir_sets_owner_only_permissions() {
296        use std::os::unix::fs::PermissionsExt;
297
298        let dir =
299            std::env::temp_dir().join(format!("seer-ensure-log-dir-test-{}", std::process::id()));
300        let _ = std::fs::remove_dir_all(&dir);
301
302        // Fresh directory must come out owner-only.
303        super::ensure_log_dir(&dir);
304        let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
305        assert_eq!(
306            mode & 0o777,
307            0o700,
308            "fresh log dir must be owner-only, got {:o}",
309            mode & 0o777
310        );
311
312        // A pre-existing directory with loose permissions (e.g. created by an
313        // older version with defaults) must be tightened, not left as-is.
314        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
315        super::ensure_log_dir(&dir);
316        let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
317        assert_eq!(
318            mode & 0o777,
319            0o700,
320            "pre-existing log dir must be tightened to owner-only, got {:o}",
321            mode & 0o777
322        );
323
324        let _ = std::fs::remove_dir_all(&dir);
325    }
326}