Skip to main content

dig_logging/
init.rs

1//! [`init`] — the one entry point that installs the DIG logging stack (SPEC §1).
2//!
3//! It resolves the log directory, opens the rolling file appender, wires the JSONL file sink + the
4//! human stderr sink behind one reloadable level filter, stamps the correlation ids, and spawns the
5//! hourly maintenance task (byte-cap janitor + dropped-line reporter). It returns a [`LogGuard`] the
6//! caller holds for the process lifetime; dropping it flushes the file writer.
7//!
8//! **The file sink degrades; it never silences the process.** A log directory the process cannot
9//! write to — on Windows, a `%ProgramData%` service dir owned by the SERVICE account and opened by an
10//! interactive run — used to fail `init` outright, so the console sink was never installed either and
11//! the binary ran with NO subscriber at all. That turns every downstream bug into a mystery: a broken
12//! subsystem looks dead rather than broken. Now a file-sink failure installs the console sink anyway,
13//! prints ONE warning naming the path and the reason, and reports itself via [`LogGuard::file_error`].
14
15use std::io::Write as _;
16use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19use tracing_appender::rolling::{Builder as AppenderBuilder, Rotation};
20use tracing_subscriber::fmt::MakeWriter;
21use tracing_subscriber::layer::SubscriberExt;
22use tracing_subscriber::util::SubscriberInitExt;
23use tracing_subscriber::{reload, EnvFilter, Layer};
24
25use crate::error::{Error, Result};
26use crate::layer::{DigJsonLayer, OwnedStatics};
27use crate::writer::{LossyWriter, WriterGuard};
28use crate::{correlation, dirs, filter, janitor, Service};
29
30/// How often the maintenance task enforces the byte cap + reports dropped lines.
31const MAINTENANCE_INTERVAL: Duration = Duration::from_secs(3600);
32
33/// A type-erased live-filter swapper capturing the `reload` handle (SPEC §5 runtime reload).
34type FilterSetter = Box<dyn Fn(&str) -> Result<()> + Send + Sync>;
35
36/// Held by the caller for the life of the process. Dropping it flushes the file writer (SPEC §4.4);
37/// it also exposes runtime level control (SPEC §5) and whether the file sink degraded.
38pub struct LogGuard {
39    _writer: Option<WriterGuard>,
40    dir: PathBuf,
41    file_error: Option<String>,
42    set_filter: FilterSetter,
43}
44
45impl LogGuard {
46    /// The resolved log directory this run is writing to. When [`file_error`](Self::file_error) is
47    /// set, nothing is being written there — the directory is the one that could NOT be opened.
48    pub fn log_dir(&self) -> &std::path::Path {
49        &self.dir
50    }
51
52    /// Why the JSONL file sink is disabled, or `None` when it is live. Console logging is installed
53    /// either way; a consumer that surfaces logging health reports this rather than treating it as
54    /// a fatal `init` failure.
55    pub fn file_error(&self) -> Option<&str> {
56        self.file_error.as_deref()
57    }
58
59    /// Swap the live level filter (SPEC §5 runtime reload). The consumer's control plane calls this.
60    pub fn set_filter(&self, directive: &str) -> Result<()> {
61        (self.set_filter)(directive)
62    }
63}
64
65/// The live JSONL file sink: the layer to install, the flush guard, and the writer the maintenance
66/// task watches for dropped lines.
67struct FileSink {
68    layer: DigJsonLayer<LossyWriter>,
69    guard: WriterGuard,
70    writer: LossyWriter,
71}
72
73/// Install the DIG logging stack for `service` (SPEC §1). Call ONCE at process start; a second call
74/// returns [`Error::AlreadyInitialized`].
75///
76/// Only two conditions still fail: a subscriber already installed, and — impossible in practice,
77/// since [`filter`] falls back to a known-good directive — an unparseable filter. An unwritable log
78/// directory does NOT fail; it degrades to console-only logging with a warning on stderr.
79pub fn init(service: Service) -> Result<LogGuard> {
80    init_with_console(service, dirs::log_dir(service.name), std::io::stderr)
81}
82
83/// The testable core of [`init`]: the log directory and the console sink are injected, so the
84/// degraded path can be exercised without touching the real environment or the real stderr.
85fn init_with_console<W>(service: Service, dir: PathBuf, console: W) -> Result<LogGuard>
86where
87    W: for<'w> MakeWriter<'w> + Send + Sync + 'static,
88{
89    let max_bytes = janitor::max_bytes(|key: &str| std::env::var(key).ok());
90
91    let (file_sink, file_error) = match open_file_sink(&dir, service, max_bytes) {
92        Ok(sink) => (Some(sink), None),
93        Err(error) => {
94            warn_file_logging_disabled(&console, &dir, &error);
95            (None, Some(error.to_string()))
96        }
97    };
98
99    let directive = filter::resolve_filter_from_env(filter::read_persisted_level(&dir).as_deref());
100    let env_filter = EnvFilter::try_new(&directive).map_err(|e| Error::Filter {
101        directive: directive.clone(),
102        message: e.to_string(),
103    })?;
104    let (filter_layer, reload_handle) = reload::Layer::new(env_filter);
105
106    let (json_layer, writer_guard, file_writer) = match file_sink {
107        Some(sink) => (Some(sink.layer), Some(sink.guard), Some(sink.writer)),
108        None => (None, None, None),
109    };
110    let console_layer = tracing_subscriber::fmt::layer()
111        .with_writer(console)
112        .compact();
113
114    tracing_subscriber::registry()
115        .with(filter_layer)
116        .with(json_layer)
117        .with(console_layer.boxed())
118        .try_init()
119        .map_err(|_| Error::AlreadyInitialized)?;
120
121    if let Some(writer) = file_writer {
122        spawn_maintenance(dir.clone(), service.name, max_bytes, writer);
123    }
124
125    let set_filter = Box::new(move |directive: &str| -> Result<()> {
126        let new = EnvFilter::try_new(directive).map_err(|e| Error::Filter {
127            directive: directive.to_string(),
128            message: e.to_string(),
129        })?;
130        reload_handle.reload(new).map_err(|e| Error::Filter {
131            directive: directive.to_string(),
132            message: e.to_string(),
133        })
134    });
135
136    Ok(LogGuard {
137        _writer: writer_guard,
138        dir,
139        file_error,
140        set_filter,
141    })
142}
143
144/// Create the log directory, enforce the byte cap, and open the rolling JSONL appender behind the
145/// non-blocking writer. Every failure here is recoverable by the caller: it costs the file sink, not
146/// the process's logging.
147fn open_file_sink(
148    dir: &Path,
149    service: Service,
150    max_bytes: u64,
151) -> std::result::Result<FileSink, Error> {
152    std::fs::create_dir_all(dir).map_err(|source| Error::LogDir {
153        path: dir.to_path_buf(),
154        source,
155    })?;
156
157    let retention = janitor::retention_days(|key: &str| std::env::var(key).ok());
158    janitor::enforce_byte_cap(dir, service.name, max_bytes);
159
160    let appender = AppenderBuilder::new()
161        .rotation(Rotation::DAILY)
162        .filename_prefix(format!("{}.jsonl", service.name))
163        .max_log_files(retention)
164        .build(dir)
165        .map_err(|source| Error::Appender {
166            path: dir.to_path_buf(),
167            source,
168        })?;
169    let (writer, guard) = crate::writer::spawn(appender);
170
171    let statics = OwnedStatics {
172        service: service.name.to_string(),
173        service_version: service.version.to_string(),
174        run_context: service.run_context.as_str().to_string(),
175        run_id: correlation::new_run_id(),
176        parent_op_id: correlation::parent_op_id_from_env(),
177    };
178
179    Ok(FileSink {
180        layer: DigJsonLayer::new(statics, writer.clone()),
181        guard,
182        writer,
183    })
184}
185
186/// Tell the operator, on the console sink itself, that file logging is off and why. A SILENT degrade
187/// is only marginally better than a silent failure: whoever later looks for the JSONL file must be
188/// able to see that it was never written, and which path failed.
189fn warn_file_logging_disabled<W>(console: &W, dir: &Path, error: &Error)
190where
191    W: for<'w> MakeWriter<'w>,
192{
193    // Pre-subscriber, so this cannot be a `tracing` event — it is written straight to the sink.
194    let _ = writeln!(
195        console.make_writer(),
196        "WARN dig-logging: file logging is DISABLED for {} ({}). Console logging continues; set \
197         DIG_LOG_DIR to a writable directory to restore JSONL log files.",
198        dir.display(),
199        error
200    );
201}
202
203/// Spawn the hourly maintenance task: enforce the byte cap, and emit a `WARN` whenever the file
204/// writer has dropped new lines under backpressure (SPEC §4/§4.4).
205fn spawn_maintenance(dir: PathBuf, service: &'static str, max_bytes: u64, writer: LossyWriter) {
206    std::thread::Builder::new()
207        .name("dig-logging-maintenance".into())
208        .spawn(move || {
209            let mut last_dropped = 0u64;
210            loop {
211                std::thread::sleep(MAINTENANCE_INTERVAL);
212                janitor::enforce_byte_cap(&dir, service, max_bytes);
213                let dropped = writer.dropped();
214                if dropped > last_dropped {
215                    tracing::warn!(
216                        target: "dig_logging",
217                        dropped,
218                        "log lines dropped under backpressure since start"
219                    );
220                    last_dropped = dropped;
221                }
222            }
223        })
224        .expect("spawn dig-logging maintenance thread");
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use std::sync::{Arc, Mutex};
231
232    /// A console sink that keeps every byte, so a test can read what an operator would have seen.
233    #[derive(Clone, Default)]
234    struct Captured(Arc<Mutex<Vec<u8>>>);
235
236    impl Captured {
237        fn text(&self) -> String {
238            String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned()
239        }
240    }
241
242    impl std::io::Write for Captured {
243        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
244            self.0.lock().unwrap().extend_from_slice(buf);
245            Ok(buf.len())
246        }
247        fn flush(&mut self) -> std::io::Result<()> {
248            Ok(())
249        }
250    }
251
252    impl<'a> MakeWriter<'a> for Captured {
253        type Writer = Captured;
254        fn make_writer(&'a self) -> Self::Writer {
255            self.clone()
256        }
257    }
258
259    /// The regression for #3064: an unusable log directory must cost the FILE sink only. Before the
260    /// fix, `init` returned `Err` here and the process ran with no subscriber at all — the console
261    /// capture would be empty, which is exactly what this asserts against.
262    ///
263    /// The subscriber is process-global and install-once, so this is the ONE test in the lib test
264    /// binary that installs one; the file-sink success path is covered end-to-end in
265    /// `tests/end_to_end.rs`, which runs as its own process.
266    #[test]
267    fn unwritable_log_dir_degrades_to_console_instead_of_silencing_logging() {
268        let tmp = tempfile::tempdir().unwrap();
269        // A regular FILE where the log directory should be: `create_dir_all` genuinely fails, the
270        // same class of failure as a service-owned `%ProgramData%` dir an interactive run cannot open.
271        let blocked = tmp.path().join("blocked");
272        std::fs::write(&blocked, b"not a directory").unwrap();
273
274        let console = Captured::default();
275        let guard = init_with_console(
276            Service {
277                name: "dig-node",
278                version: "9.9.9",
279                run_context: crate::RunContext::Cli,
280            },
281            blocked.clone(),
282            console.clone(),
283        )
284        .expect("an unwritable log dir must not fail init");
285
286        let warning = console.text();
287        assert!(
288            warning.contains(&blocked.display().to_string()),
289            "the warning names the path that failed; got: {warning:?}"
290        );
291        assert!(
292            guard.file_error().is_some(),
293            "the degrade is reportable to the caller"
294        );
295
296        tracing::info!(target: "dig_logging_test", "console still receives this record");
297
298        let logged = console.text();
299        assert!(
300            logged.contains("console still receives this record"),
301            "records must still reach the console sink; got: {logged:?}"
302        );
303    }
304}