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");
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`].
40pub fn init_logging(app_name: &str) -> LogGuard {
41    init_logging_with_writer(app_name, std::io::stderr)
42}
43
44/// Initialise the global tracing subscriber with a custom console writer.
45///
46/// This is used by `seer-cli` to route log output through the progress bar.
47pub fn init_logging_with_writer<W>(app_name: &str, writer: W) -> LogGuard
48where
49    W: for<'a> MakeWriter<'a> + Send + Sync + 'static,
50{
51    // Guard against double-init (e.g. test harnesses).
52    if INITIALIZED.set(()).is_err() {
53        return LogGuard { _file_guard: None };
54    }
55
56    let env_filter = build_env_filter();
57    let log_format = read_env("ARCANUM_LOG_FORMAT", "text");
58    let file_enabled = matches!(
59        read_env("ARCANUM_LOG_FILE", "").to_lowercase().as_str(),
60        "1" | "true" | "yes"
61    );
62
63    let json_mode = log_format == "json";
64
65    // Build file appender layer if enabled
66    let (file_layer_json, file_layer_text, file_guard) = if file_enabled {
67        let dir = log_dir();
68        std::fs::create_dir_all(&dir).ok();
69        let file_appender = tracing_appender::rolling::daily(&dir, format!("{app_name}.log"));
70        let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
71
72        if json_mode {
73            (
74                Some(fmt::layer().json().with_writer(non_blocking).boxed()),
75                None,
76                Some(guard),
77            )
78        } else {
79            (
80                None,
81                Some(fmt::layer().with_writer(non_blocking).boxed()),
82                Some(guard),
83            )
84        }
85    } else {
86        (None, None, None)
87    };
88
89    // Build console layer
90    let (console_json, console_text) = if json_mode {
91        (Some(fmt::layer().json().with_writer(writer).boxed()), None)
92    } else {
93        (None, Some(fmt::layer().with_writer(writer).boxed()))
94    };
95
96    // Build optional OpenTelemetry OTLP layer (boxed for type erasure).
97    let otel_layer = build_otel_layer(app_name).map(|l| l.boxed());
98
99    // Use try_init() — if another subscriber is already registered (e.g.,
100    // when both seer-core and tome-core are linked into the same process),
101    // this silently succeeds without panicking.
102    let _ = tracing_subscriber::registry()
103        .with(env_filter)
104        .with(console_json)
105        .with(console_text)
106        .with(file_layer_json)
107        .with(file_layer_text)
108        .with(otel_layer)
109        .try_init();
110
111    LogGuard {
112        _file_guard: file_guard,
113    }
114}
115
116/// Returns the resolved log directory.
117///
118/// Reads `ARCANUM_LOG_DIR`, falls back to `~/.arcanum/logs/`.
119pub fn log_dir() -> PathBuf {
120    if let Ok(dir) = std::env::var("ARCANUM_LOG_DIR") {
121        return PathBuf::from(dir);
122    }
123    dirs::home_dir()
124        .unwrap_or_else(|| PathBuf::from("."))
125        .join(".arcanum")
126        .join("logs")
127}
128
129// ---- internal helpers ----
130
131fn build_env_filter() -> EnvFilter {
132    let level = read_env_chain(&["ARCANUM_LOG_LEVEL", "RUST_LOG"], "warn");
133    EnvFilter::try_new(&level).unwrap_or_else(|_| EnvFilter::new("warn"))
134}
135
136fn read_env(key: &str, default: &str) -> String {
137    std::env::var(key).unwrap_or_else(|_| default.to_string())
138}
139
140fn read_env_chain(keys: &[&str], default: &str) -> String {
141    for key in keys {
142        if let Ok(val) = std::env::var(key) {
143            if !val.is_empty() {
144                return val;
145            }
146        }
147    }
148    default.to_string()
149}
150
151/// Build the OpenTelemetry OTLP tracing layer if `ARCANUM_OTEL_ENDPOINT` is
152/// set.  Returns `None` (zero cost) when the env var is absent.
153fn build_otel_layer<S>(
154    service_name: &str,
155) -> Option<tracing_opentelemetry::OpenTelemetryLayer<S, opentelemetry_sdk::trace::SdkTracer>>
156where
157    S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
158{
159    use opentelemetry::trace::TracerProvider as _;
160    use opentelemetry_otlp::WithExportConfig as _;
161
162    let endpoint = std::env::var("ARCANUM_OTEL_ENDPOINT").ok()?;
163    if endpoint.is_empty() {
164        return None;
165    }
166
167    // Build the OTLP exporter → tracer → layer.
168    let exporter = opentelemetry_otlp::SpanExporter::builder()
169        .with_tonic()
170        .with_endpoint(&endpoint)
171        .build()
172        .ok()?;
173
174    let tracer_provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
175        .with_batch_exporter(exporter)
176        .with_resource(
177            opentelemetry_sdk::Resource::builder()
178                .with_service_name(service_name.to_string())
179                .build(),
180        )
181        .build();
182
183    let tracer = tracer_provider.tracer(service_name.to_string());
184
185    // Keep the provider alive — leaking is acceptable here because it lives
186    // for the process lifetime and must not be dropped before shutdown.
187    std::mem::forget(tracer_provider);
188
189    Some(tracing_opentelemetry::layer().with_tracer(tracer))
190}