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