dig_logging/filter.rs
1//! Level-filter resolution (SPEC §5).
2//!
3//! One `EnvFilter` drives both sinks. Its directive is chosen by a fixed precedence — a persisted
4//! operator choice, then the ecosystem-common `DIG_LOG`, then the Rust-conventional `RUST_LOG`, then
5//! a noise-trimmed default. The choice is a PURE function of its inputs so the precedence is
6//! table-testable without the process environment.
7//!
8//! Every candidate directive is VALIDATED against [`EnvFilter`](tracing_subscriber::EnvFilter)
9//! before it is chosen (#726). A directive that does not parse is skipped, and resolution falls
10//! through to the next valid source — ultimately the always-valid [`DEFAULT_DIRECTIVE`]. This is
11//! load-bearing: the persisted `level` file survives restarts, so a single bad `logs level`
12//! directive would otherwise make `init` fail on EVERY subsequent start and leave the service with
13//! NO structured logging. Logging is NEVER disabled because a bad directive was persisted.
14
15/// The env var carrying an operator-set filter (ecosystem-common name), checked before `RUST_LOG`.
16pub const ENV_DIG_LOG: &str = "DIG_LOG";
17
18/// The Rust-conventional filter env var, kept working as the lowest-priority env source.
19pub const ENV_RUST_LOG: &str = "RUST_LOG";
20
21/// The baked-in default: `info`, with the chattiest transport crates trimmed to `warn`.
22pub const DEFAULT_DIRECTIVE: &str = "info,hyper=warn,rustls=warn,h2=warn,tower=warn";
23
24/// Resolve the effective filter directive by precedence (SPEC §5): the first non-empty of
25/// `persisted` > `dig_log` > `rust_log`, else [`DEFAULT_DIRECTIVE`]. Whitespace-only inputs count as
26/// empty (an exported-but-blank env var must not silence logging).
27pub fn resolve_filter(
28 persisted: Option<&str>,
29 dig_log: Option<&str>,
30 rust_log: Option<&str>,
31) -> String {
32 [persisted, dig_log, rust_log]
33 .into_iter()
34 .flatten()
35 .map(str::trim)
36 .find(|value| !value.is_empty())
37 .unwrap_or(DEFAULT_DIRECTIVE)
38 .to_string()
39}
40
41/// Does a directive parse as a valid [`EnvFilter`](tracing_subscriber::EnvFilter)? Pure — parsing a
42/// directive string does not read the process environment, so this stays table-testable.
43fn directive_is_valid(directive: &str) -> bool {
44 tracing_subscriber::EnvFilter::try_new(directive).is_ok()
45}
46
47/// Resolve the effective directive by precedence AND validity (SPEC §5, #726): the first source
48/// that is both non-empty AND parses as a valid `EnvFilter`, else the always-valid
49/// [`DEFAULT_DIRECTIVE`]. A garbage persisted/env directive is thus SKIPPED rather than propagated —
50/// logging is never disabled because a bad directive was supplied.
51pub fn resolve_valid_filter(
52 persisted: Option<&str>,
53 dig_log: Option<&str>,
54 rust_log: Option<&str>,
55) -> String {
56 [persisted, dig_log, rust_log]
57 .into_iter()
58 .flatten()
59 .map(str::trim)
60 .find(|value| !value.is_empty() && directive_is_valid(value))
61 .unwrap_or(DEFAULT_DIRECTIVE)
62 .to_string()
63}
64
65/// Resolve the directive from the real environment given an optional persisted choice. Validates
66/// every candidate ([`resolve_valid_filter`]) so the returned directive always parses (#726).
67pub fn resolve_filter_from_env(persisted: Option<&str>) -> String {
68 let dig_log = std::env::var(ENV_DIG_LOG).ok();
69 let rust_log = std::env::var(ENV_RUST_LOG).ok();
70 resolve_valid_filter(persisted, dig_log.as_deref(), rust_log.as_deref())
71}
72
73/// The file (inside the service log dir) an operator's persisted `logs level` choice is stored in.
74pub const LEVEL_FILE: &str = "level";
75
76/// Read the persisted level directive from a service log `dir`, if any (`None` when absent/blank).
77pub fn read_persisted_level(dir: &std::path::Path) -> Option<String> {
78 std::fs::read_to_string(dir.join(LEVEL_FILE))
79 .ok()
80 .map(|value| value.trim().to_string())
81 .filter(|value| !value.is_empty())
82}
83
84/// Persist an operator's level directive into a service log `dir` (SPEC §5 / §8.1 `logs level`).
85///
86/// Rejects an invalid directive up-front ([`std::io::ErrorKind::InvalidInput`]) so garbage is never
87/// persisted (#726) — a typo in `logs level` fails loudly at write time instead of silently
88/// breaking logging on the next start. The read path ([`resolve_valid_filter`]) is the belt-and-
89/// braces backstop for any directive persisted before this validation existed.
90pub fn write_persisted_level(dir: &std::path::Path, directive: &str) -> std::io::Result<()> {
91 let directive = directive.trim();
92 if !directive_is_valid(directive) {
93 return Err(std::io::Error::new(
94 std::io::ErrorKind::InvalidInput,
95 format!("invalid log filter directive: {directive:?}"),
96 ));
97 }
98 std::fs::create_dir_all(dir)?;
99 std::fs::write(dir.join(LEVEL_FILE), directive)
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn persisted_beats_everything() {
108 assert_eq!(
109 resolve_filter(Some("debug"), Some("trace"), Some("warn")),
110 "debug"
111 );
112 }
113
114 #[test]
115 fn dig_log_beats_rust_log() {
116 assert_eq!(resolve_filter(None, Some("trace"), Some("warn")), "trace");
117 }
118
119 #[test]
120 fn rust_log_used_when_only_source() {
121 assert_eq!(resolve_filter(None, None, Some("error")), "error");
122 }
123
124 #[test]
125 fn default_when_all_absent_or_blank() {
126 assert_eq!(resolve_filter(None, None, None), DEFAULT_DIRECTIVE);
127 assert_eq!(
128 resolve_filter(Some(" "), Some(""), None),
129 DEFAULT_DIRECTIVE
130 );
131 }
132
133 // --- #726: a persisted GARBAGE directive must never disable logging ---
134
135 /// REGRESSION (#726): a garbage persisted directive is INVALID for `EnvFilter`. With no env
136 /// override, resolution must fall back to the known-valid [`DEFAULT_DIRECTIVE`] — NEVER return
137 /// the garbage (which would make `init` error and the consumer serve with NO structured log).
138 #[test]
139 fn garbage_persisted_falls_back_to_default() {
140 // An invalid LEVEL name after `=` is what `EnvFilter::try_new` rejects.
141 assert_eq!(
142 resolve_valid_filter(Some("target=boguslevel"), None, None),
143 DEFAULT_DIRECTIVE
144 );
145 }
146
147 /// A garbage persisted directive is SKIPPED in favour of a still-valid lower-precedence source
148 /// (a valid `RUST_LOG`), rather than collapsing straight to the default.
149 #[test]
150 fn garbage_persisted_falls_through_to_valid_env() {
151 assert_eq!(
152 resolve_valid_filter(Some("info,bad=notalevel"), None, Some("debug")),
153 "debug"
154 );
155 }
156
157 /// A VALID persisted directive still wins by precedence (validation must not change the happy path).
158 #[test]
159 fn valid_persisted_still_wins() {
160 assert_eq!(
161 resolve_valid_filter(Some("debug,h2=warn"), Some("trace"), None),
162 "debug,h2=warn"
163 );
164 }
165
166 /// `resolve_filter_from_env` reading a garbage persisted `level` file yields a VALID directive
167 /// (the default), so `EnvFilter::try_new` on it always succeeds and `init` never fails.
168 #[test]
169 fn from_env_with_garbage_persisted_is_valid() {
170 let resolved = resolve_filter_from_env(Some("app=verboze"));
171 assert!(
172 tracing_subscriber::EnvFilter::try_new(&resolved).is_ok(),
173 "resolved directive must parse: {resolved}"
174 );
175 }
176
177 /// `write_persisted_level` rejects an invalid directive up-front so garbage is never persisted.
178 #[test]
179 fn write_rejects_invalid_directive() {
180 let dir = tempfile::tempdir().unwrap();
181 assert!(write_persisted_level(dir.path(), "target=boguslevel").is_err());
182 assert_eq!(
183 read_persisted_level(dir.path()),
184 None,
185 "nothing should have been persisted"
186 );
187 }
188
189 #[test]
190 fn persisted_level_round_trips_and_blank_is_none() {
191 let dir = tempfile::tempdir().unwrap();
192 assert_eq!(read_persisted_level(dir.path()), None);
193 write_persisted_level(dir.path(), " debug,h2=warn ").unwrap();
194 assert_eq!(
195 read_persisted_level(dir.path()),
196 Some("debug,h2=warn".to_string())
197 );
198 write_persisted_level(dir.path(), " ").unwrap();
199 assert_eq!(
200 read_persisted_level(dir.path()),
201 None,
202 "blank persisted level reads as unset"
203 );
204 }
205}