Skip to main content

vault_core/
telemetry.rs

1//! Structured logging, shared by all three binaries.
2//!
3//! Before this module the workspace had no `tracing`, no metrics crate and no
4//! structured log anywhere: a failure in production left `println!` output and
5//! nothing correlatable. Everything in the operability goals — uptime
6//! monitoring, failure detection, API analytics — depends on this existing
7//! first, which is why it landed ahead of the rest of its phase.
8//!
9//! # Three rules, and they are not style preferences
10//!
11//! 1. **Logs go to stderr, always.** The CLI's stdout is a machine-readable
12//!    contract (`{"ok":true,…}`); a log line written there corrupts the JSON
13//!    envelope an agent is parsing. `with_writer(std::io::stderr)` is what makes
14//!    `unv … --json | jq` keep working with `UNV_LOG=debug` set.
15//! 2. **Never log a secret value.** Log the fingerprint (`out::fingerprint`),
16//!    the entry id, or the provider name — never `api_key`, never a password,
17//!    never a session token. A log file is not encrypted and is routinely
18//!    shipped somewhere else.
19//! 3. **Logging is off unless asked for.** The default level is chosen by each
20//!    binary, and every one of them is quiet enough that normal operation
21//!    produces nothing on stderr. A secrets manager that chatters is a secrets
22//!    manager whose output nobody reads.
23//!
24//! # Configuring it
25//!
26//! | Variable | Effect |
27//! | -------- | ------ |
28//! | `UNV_LOG` | `tracing` filter directive (`info`, `unv_server=debug`, …). Checked first. |
29//! | `RUST_LOG` | Same, checked only when `UNV_LOG` is unset, so an unrelated `RUST_LOG` in the environment still works. |
30//! | `UNV_LOG_FORMAT` | `json` for one JSON object per line; anything else is the human format. |
31//!
32//! An unparseable filter falls back to the caller's default rather than
33//! panicking: a typo in an environment variable must not stop a server booting.
34
35use std::sync::OnceLock;
36
37/// Set once by the first successful [`init`], so a second call is a no-op
38/// rather than a panic from `tracing`'s global-subscriber guard. The desktop
39/// app can host `unv-server`'s router in-process, which is exactly the case
40/// where two `init` calls happen in one program.
41static INITIALISED: OnceLock<()> = OnceLock::new();
42
43/// Installs the process-wide subscriber. Safe to call more than once; only the
44/// first call has any effect.
45///
46/// `service` names the binary and is attached to the startup line so a merged
47/// log tells `unv-server` apart from a desktop app hosting the same router.
48/// `default_level` applies when neither `UNV_LOG` nor `RUST_LOG` is set.
49pub fn init(service: &str, default_level: &str) {
50    if INITIALISED.set(()).is_err() {
51        return;
52    }
53
54    let directive = std::env::var("UNV_LOG")
55        .or_else(|_| std::env::var("RUST_LOG"))
56        .unwrap_or_else(|_| default_level.to_string());
57
58    let filter = tracing_subscriber::EnvFilter::try_new(&directive)
59        .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level));
60
61    let json = std::env::var("UNV_LOG_FORMAT")
62        .map(|v| v.eq_ignore_ascii_case("json"))
63        .unwrap_or(false);
64
65    // `try_init` rather than `init`: another crate in the process may already
66    // own the global subscriber, and losing that race is not an error worth
67    // aborting a boot over.
68    if json {
69        let _ = tracing_subscriber::fmt()
70            .with_env_filter(filter)
71            .with_writer(std::io::stderr)
72            .json()
73            .try_init();
74    } else {
75        let _ = tracing_subscriber::fmt()
76            .with_env_filter(filter)
77            .with_writer(std::io::stderr)
78            .try_init();
79    }
80
81    tracing::debug!(
82        service,
83        version = env!("CARGO_PKG_VERSION"),
84        filter = %directive,
85        "logging initialised"
86    );
87}
88
89/// True once [`init`] has run in this process. Tests use it; nothing else
90/// should need to ask.
91pub fn is_initialised() -> bool {
92    INITIALISED.get().is_some()
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn init_is_idempotent() {
101        init("test", "error");
102        assert!(is_initialised());
103        // A second call must not panic on tracing's global-subscriber guard.
104        init("test", "error");
105        assert!(is_initialised());
106    }
107}