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        std::fs::create_dir_all(&dir).ok();
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// ---- internal helpers ----
144
145fn build_env_filter(default_level: &str) -> EnvFilter {
146    let level = read_env_chain(&["ARCANUM_LOG_LEVEL", "RUST_LOG"], default_level);
147    EnvFilter::try_new(&level).unwrap_or_else(|_| EnvFilter::new(default_level))
148}
149
150fn read_env(key: &str, default: &str) -> String {
151    std::env::var(key).unwrap_or_else(|_| default.to_string())
152}
153
154fn read_env_chain(keys: &[&str], default: &str) -> String {
155    for key in keys {
156        if let Ok(val) = std::env::var(key) {
157            if !val.is_empty() {
158                return val;
159            }
160        }
161    }
162    default.to_string()
163}
164
165/// Build the OpenTelemetry OTLP tracing layer if `ARCANUM_OTEL_ENDPOINT` is
166/// set. Returns `None` (zero cost) when the env var is absent. Only compiled
167/// when the `otel` feature is enabled.
168#[cfg(feature = "otel")]
169fn build_otel_layer<S>(
170    service_name: &str,
171) -> Option<tracing_opentelemetry::OpenTelemetryLayer<S, opentelemetry_sdk::trace::SdkTracer>>
172where
173    S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
174{
175    use opentelemetry::trace::TracerProvider as _;
176    use opentelemetry_otlp::WithExportConfig as _;
177
178    let endpoint = std::env::var("ARCANUM_OTEL_ENDPOINT").ok()?;
179    if endpoint.is_empty() {
180        return None;
181    }
182
183    // Build the OTLP exporter → tracer → layer.
184    let exporter = opentelemetry_otlp::SpanExporter::builder()
185        .with_tonic()
186        .with_endpoint(&endpoint)
187        .build()
188        .ok()?;
189
190    let tracer_provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
191        .with_batch_exporter(exporter)
192        .with_resource(
193            opentelemetry_sdk::Resource::builder()
194                .with_service_name(service_name.to_string())
195                .build(),
196        )
197        .build();
198
199    let tracer = tracer_provider.tracer(service_name.to_string());
200
201    // Keep the provider alive — leaking is acceptable here because it lives
202    // for the process lifetime and must not be dropped before shutdown.
203    std::mem::forget(tracer_provider);
204
205    Some(tracing_opentelemetry::layer().with_tracer(tracer))
206}
207
208#[cfg(test)]
209mod tests {
210    use super::build_env_filter;
211
212    // Serialize env-var tests so parallel runs don't collide on the shared
213    // process env.
214    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
215
216    fn with_clean_env<F: FnOnce() -> R, R>(f: F) -> R {
217        let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
218        let prev_arcanum = std::env::var("ARCANUM_LOG_LEVEL").ok();
219        let prev_rust = std::env::var("RUST_LOG").ok();
220        std::env::remove_var("ARCANUM_LOG_LEVEL");
221        std::env::remove_var("RUST_LOG");
222        let result = f();
223        match prev_arcanum {
224            Some(v) => std::env::set_var("ARCANUM_LOG_LEVEL", v),
225            None => std::env::remove_var("ARCANUM_LOG_LEVEL"),
226        }
227        match prev_rust {
228            Some(v) => std::env::set_var("RUST_LOG", v),
229            None => std::env::remove_var("RUST_LOG"),
230        }
231        result
232    }
233
234    #[test]
235    fn test_default_level_used_when_no_env_set() {
236        with_clean_env(|| {
237            let filter = build_env_filter("error");
238            // EnvFilter's Display renders the directive set.
239            assert_eq!(format!("{}", filter), "error");
240        });
241    }
242
243    #[test]
244    fn test_arcanum_log_level_overrides_default() {
245        with_clean_env(|| {
246            std::env::set_var("ARCANUM_LOG_LEVEL", "debug");
247            let filter = build_env_filter("error");
248            assert_eq!(format!("{}", filter), "debug");
249        });
250    }
251
252    #[test]
253    fn test_rust_log_overrides_default() {
254        with_clean_env(|| {
255            std::env::set_var("RUST_LOG", "info");
256            let filter = build_env_filter("error");
257            assert_eq!(format!("{}", filter), "info");
258        });
259    }
260
261    #[test]
262    fn test_arcanum_log_level_takes_precedence_over_rust_log() {
263        with_clean_env(|| {
264            std::env::set_var("ARCANUM_LOG_LEVEL", "warn");
265            std::env::set_var("RUST_LOG", "trace");
266            let filter = build_env_filter("error");
267            assert_eq!(format!("{}", filter), "warn");
268        });
269    }
270}