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}