Skip to main content

acme_proxy/cli/
logging.rs

1//! `[logging]` — resolving the configuration into an installed subscriber.
2//!
3//! Split out of `cli/mod.rs`, where it was ~95 production lines with no
4//! coupling to anything else in the file: the clap tree, `dispatch` and the
5//! `serve*` chain never call any of it beyond the single `init_logging` in
6//! [`run`](super::run).
7//!
8//! The rule the whole module follows: **every knob is validated before anything
9//! is installed, and an unknown value is an error the caller prints and exits
10//! on rather than a silent fallback.** A certificate authority running at a log
11//! level or to a destination its operator did not ask for is worse than one
12//! that refuses to start and says why.
13//!
14//! # Reloading
15//!
16//! All six keys reload on `SIGHUP`, which is why the whole stack is built as one
17//! [`Installed`] layer behind a [`tracing_subscriber::reload::Layer`] rather
18//! than through the `tracing_subscriber::fmt()` builder. Three things that shape
19//! rests on, each a bug if reversed:
20//!
21//! - **The filter is composed with [`Layer::and_then`], never `with_filter`.**
22//!   `reload::Handle::reload` is documented as unusable with a
23//!   [`tracing_subscriber::filter::Filtered`] layer (tokio-rs/tracing#1629),
24//!   because replacing it mints a filter id the registry never saw. `and_then`
25//!   is global filtering — `Layered::enabled` is the conjunction of both halves
26//!   — which is exactly the semantics `with_env_filter` gave before.
27//! - **One boxed layer, not two.** `Box<dyn Layer<S>>` has to name its `S`, and
28//!   a second `.with()` makes the next layer's `S` the `Layered<…>` of the
29//!   first — a type nothing can write down in a `static`. One box is also one
30//!   lock rather than two.
31//! - **The handle lives in a process-wide [`OnceLock`], beside the global it is
32//!   a handle to.** The subscriber already is process-global (`.init()` panics
33//!   on a second call); this is not a second one. Threading the handle from
34//!   `main.rs` to `cli::apply_reload` instead would touch six signatures,
35//!   including the `serve_on*` seams every test enters through. When it is
36//!   unset — a test binary, or a consumer that installed its own subscriber —
37//!   [`publish_logging`] is a **no-op that says so**, since logging is then not
38//!   ours to swap.
39//!
40//! The cost, stated rather than buried: a `reload::Layer` puts an `RwLock` read
41//! on every event.
42
43use std::sync::OnceLock;
44
45use tracing_subscriber::fmt::format::FmtSpan;
46use tracing_subscriber::fmt::writer::BoxMakeWriter;
47use tracing_subscriber::layer::SubscriberExt;
48use tracing_subscriber::util::SubscriberInitExt;
49use tracing_subscriber::{EnvFilter, Layer, Registry, reload};
50
51/// The layer stack as one value, so a reload can replace all of it at once.
52///
53/// Boxed against `Registry` specifically: that is the base subscriber both
54/// [`init_logging`] and every swap build on.
55type Installed = Box<dyn Layer<Registry> + Send + Sync>;
56
57/// The handle to the installed stack, or unset when this process installed no
58/// subscriber of its own. See the reloading notes in the module doc.
59static RELOAD: OnceLock<reload::Handle<Installed, Registry>> = OnceLock::new();
60
61/// The tracing filter, and where it came from.
62///
63/// The provenance travels with the filter because it decides whether an
64/// operator is owed a warning: with `RUST_LOG` set, editing `logging.filter`
65/// and reloading changes nothing at all, and a silent no-op is the one outcome
66/// worth a line in the log.
67#[derive(Debug)]
68struct ResolvedFilter {
69    filter: EnvFilter,
70    from_env: bool,
71}
72
73/// Builds the tracing filter: `RUST_LOG` if set and valid, else
74/// `logging.filter`.
75///
76/// Returns an error rather than unwrapping. `logging.filter` is
77/// operator-supplied and environment-overridable, so a typo in it used to
78/// panic the process with a backtrace — four lines after a configuration error
79/// was handled cleanly with a message and an exit code. Exiting rather than
80/// silently falling back to a default is the deliberate half: a certificate
81/// authority quietly running at a different log level than its operator asked
82/// for is worse than one that refuses to start and says why.
83///
84/// The `RUST_LOG`-wins precedence is the same on a reload as at startup, and
85/// deliberately: the two disagreeing about what the server is running would be
86/// worse than the override itself.
87fn build_env_filter(logging: &crate::config::LoggingConfig) -> Result<ResolvedFilter, String> {
88    if let Ok(filter) = EnvFilter::try_from_default_env() {
89        return Ok(ResolvedFilter {
90            filter,
91            from_env: true,
92        });
93    }
94    EnvFilter::try_new(&logging.filter)
95        .map(|filter| ResolvedFilter {
96            filter,
97            from_env: false,
98        })
99        .map_err(|error| {
100            format!(
101                "configuration error: logging.filter `{}` is not a valid tracing filter: {error}",
102                logging.filter
103            )
104        })
105}
106
107/// Resolves `logging.target` to the writer records are sent to.
108///
109/// Boxed so both values leave this function as one type — the two `fmt`
110/// builder chains below are already split by `json_format`, and splitting them
111/// again by writer would be four arms saying the same thing.
112fn parse_target(target: &str) -> Result<BoxMakeWriter, String> {
113    match target {
114        "stdout" => Ok(BoxMakeWriter::new(std::io::stdout)),
115        "stderr" => Ok(BoxMakeWriter::new(std::io::stderr)),
116        other => Err(format!(
117            "configuration error: logging.target `{other}` is not a known target (stdout, stderr)"
118        )),
119    }
120}
121
122/// Resolves `logging.span_events` to the span lifecycle records emitted.
123///
124/// `close` is the one worth reaching for: it emits a record as each span ends,
125/// carrying the time spent busy and idle inside it — per-request timing without
126/// a metrics endpoint. `full` adds `new`/`enter`/`exit` and is a debugging tool,
127/// not something to run a server on.
128fn parse_span_events(span_events: &str) -> Result<FmtSpan, String> {
129    match span_events {
130        "none" => Ok(FmtSpan::NONE),
131        "close" => Ok(FmtSpan::CLOSE),
132        "full" => Ok(FmtSpan::FULL),
133        other => Err(format!(
134            "configuration error: logging.span_events `{other}` is not a known value (none, close, full)"
135        )),
136    }
137}
138
139/// Whether to colour the human-readable format: `logging.ansi`, with `NO_COLOR`
140/// able to veto it.
141///
142/// `tracing-subscriber` honours `NO_COLOR` in its own default, and calling
143/// `with_ansi` at all replaces that default outright — so configuring this key
144/// naively would have silently broken the convention for every operator who
145/// relies on it. Either switch turns colour off; neither can turn it on against
146/// the other.
147///
148/// Per the convention, `NO_COLOR` counts only when set to a non-empty value —
149/// which is [`super::style::no_color_set`]'s judgement, shared with the admin
150/// CLI's own `--color` so the two answers cannot drift. Note the *precedence*
151/// deliberately does not match: a `--color always` outranks `NO_COLOR` where
152/// this key cannot, because a flag is typed and a configuration file is
153/// ambient. [`super::style`]'s module doc has the argument.
154fn ansi_enabled(configured: bool, no_color: Option<&str>) -> bool {
155    configured && !super::style::no_color_set(no_color)
156}
157
158/// A layer stack built from `[logging]` but not yet installed.
159///
160/// The build/publish split is [`crate::Assembly::build_dispatchers`] and
161/// `publish_notifiers`', for the same reason: a reload must be able to fail
162/// *after* building this and still leave the running configuration untouched.
163pub(crate) struct PreparedLogging {
164    layer: Installed,
165    /// `RUST_LOG` was set and parsed, so `logging.filter` had no say.
166    pub(crate) filter_from_env: bool,
167}
168
169/// Resolves `[logging]` into a layer stack, validating every key.
170///
171/// The one place a stack is built, so startup and a reload cannot drift — the
172/// reasoning behind [`super::build_generation`], applied to one layer.
173pub(crate) fn prepare_logging(
174    logging: &crate::config::LoggingConfig,
175) -> Result<PreparedLogging, String> {
176    let ResolvedFilter { filter, from_env } = build_env_filter(logging)?;
177    let writer = parse_target(&logging.target)?;
178    let span_events = parse_span_events(&logging.span_events)?;
179    let ansi = ansi_enabled(logging.ansi, std::env::var("NO_COLOR").ok().as_deref());
180
181    // The two arms are separate because `.json()` changes the layer's type, not
182    // because they differ in what they configure.
183    //
184    // **`format.and_then(filter)`, never the other way round.** `and_then`
185    // makes its argument the *outer* layer, and `Layered::max_level_hint`
186    // directly over a `Registry` returns the outer hint alone — so composing
187    // them the readable way round hands the format layer's `None` to
188    // `LevelFilter::current()`, which then sits at `TRACE` for the life of the
189    // process. Every record would still be filtered correctly by `enabled`, so
190    // nothing would look wrong; the cost is that `tracing`'s static
191    // short-circuit stops working and every disabled callsite in the tree pays
192    // a subscriber call.
193    let layer: Installed = if logging.json_format {
194        tracing_subscriber::fmt::layer()
195            .json()
196            .flatten_event(logging.flatten_event)
197            .with_span_events(span_events)
198            .with_writer(writer)
199            .and_then(filter)
200            .boxed()
201    } else {
202        tracing_subscriber::fmt::layer()
203            .with_ansi(ansi)
204            .with_span_events(span_events)
205            .with_writer(writer)
206            .and_then(filter)
207            .boxed()
208    };
209
210    Ok(PreparedLogging {
211        layer,
212        filter_from_env: from_env,
213    })
214}
215
216/// Makes `prepared` the stack every later record goes through, reporting
217/// whether it took.
218///
219/// `false` means this process installed no subscriber of its own, so there is
220/// no handle and nothing was swapped — see the module doc. Synchronous, which
221/// is what lets it sit inside `cli::apply_reload`'s publishing run beside the
222/// `watch` sends.
223pub(crate) fn publish_logging(prepared: PreparedLogging) -> bool {
224    RELOAD
225        .get()
226        .is_some_and(|handle| handle.reload(prepared.layer).is_ok())
227}
228
229/// Installs the process-wide tracing subscriber from `[logging]`.
230///
231/// Every knob is validated before anything is installed, and an unknown value
232/// is an error the caller prints and exits on rather than a silent fallback —
233/// the same reasoning as [`build_env_filter`]: a certificate authority running
234/// at a log level or to a destination its operator did not ask for is worse
235/// than one that refuses to start and says why.
236pub fn init_logging(logging: &crate::config::LoggingConfig) -> Result<(), String> {
237    let prepared = prepare_logging(logging)?;
238    let (layer, handle) = reload::Layer::new(prepared.layer);
239    tracing_subscriber::registry().with(layer).init();
240    // A second install would already have panicked in `init()` above, so the
241    // only way this loses the race is a caller that never got that far.
242    let _ = RELOAD.set(handle);
243    Ok(())
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use tracing::level_filters::LevelFilter;
250
251    /// A malformed `logging.filter` is an error a caller can print, not a
252    /// panic. It is operator-supplied and environment-overridable, so a typo
253    /// used to take the process down with a backtrace — four lines after a
254    /// configuration error was handled cleanly.
255    #[test]
256    fn a_malformed_logging_filter_is_reported_rather_than_panicking() {
257        let logging = crate::config::LoggingConfig {
258            filter: "this is not=a=valid=filter".to_string(),
259            ..Default::default()
260        };
261        let error = build_env_filter(&logging).unwrap_err();
262        assert!(error.contains("logging.filter"), "{error}");
263        assert!(error.contains("this is not=a=valid=filter"), "{error}");
264    }
265
266    #[test]
267    fn a_valid_logging_filter_builds() {
268        let _guard = crate::config::ENV_LOCK
269            .lock()
270            .unwrap_or_else(|e| e.into_inner());
271        unsafe { std::env::remove_var("RUST_LOG") };
272
273        let logging = crate::config::LoggingConfig {
274            filter: "acme_proxy=debug".to_string(),
275            ..Default::default()
276        };
277        let resolved = build_env_filter(&logging).expect("a valid filter builds");
278        assert!(
279            !resolved.from_env,
280            "with RUST_LOG unset the filter comes from `logging.filter`",
281        );
282    }
283
284    /// The provenance the reload path warns off: `RUST_LOG` wins, so an edited
285    /// `logging.filter` would change nothing.
286    #[test]
287    fn rust_log_wins_and_says_so() {
288        let _guard = crate::config::ENV_LOCK
289            .lock()
290            .unwrap_or_else(|e| e.into_inner());
291        unsafe { std::env::set_var("RUST_LOG", "acme_proxy=warn") };
292
293        let logging = crate::config::LoggingConfig {
294            filter: "acme_proxy=trace".to_string(),
295            ..Default::default()
296        };
297        let resolved = build_env_filter(&logging).expect("RUST_LOG parses");
298        assert!(resolved.from_env);
299
300        unsafe { std::env::remove_var("RUST_LOG") };
301    }
302
303    /// `NO_COLOR` must keep working: `tracing-subscriber` honours it in the
304    /// default that `with_ansi` replaces, so configuring the key at all is what
305    /// put the convention at risk.
306    #[test]
307    fn no_color_vetoes_ansi_and_an_empty_value_does_not() {
308        assert!(ansi_enabled(true, None));
309        assert!(!ansi_enabled(true, Some("1")));
310        assert!(!ansi_enabled(true, Some("anything")));
311        // The convention counts only a non-empty value.
312        assert!(ansi_enabled(true, Some("")));
313        // Configured off stays off however NO_COLOR is set.
314        assert!(!ansi_enabled(false, None));
315        assert!(!ansi_enabled(false, Some("1")));
316    }
317
318    #[test]
319    fn both_logging_targets_resolve() {
320        assert!(parse_target("stdout").is_ok());
321        assert!(parse_target("stderr").is_ok());
322    }
323
324    /// A typo'd target must stop the process, not quietly pick one: an operator
325    /// who asked for `stderr` and silently got `stdout` would look for the log
326    /// in the wrong stream.
327    #[test]
328    fn an_unknown_logging_target_is_reported() {
329        let error = parse_target("syslog").unwrap_err();
330        assert!(error.contains("logging.target"), "{error}");
331        assert!(error.contains("syslog"), "{error}");
332        assert!(error.contains("stdout"), "{error}");
333    }
334
335    #[test]
336    fn every_span_events_value_resolves() {
337        assert_eq!(parse_span_events("none").unwrap(), FmtSpan::NONE);
338        assert_eq!(parse_span_events("close").unwrap(), FmtSpan::CLOSE);
339        assert_eq!(parse_span_events("full").unwrap(), FmtSpan::FULL);
340    }
341
342    #[test]
343    fn an_unknown_span_events_value_is_reported() {
344        let error = parse_span_events("enter").unwrap_err();
345        assert!(error.contains("logging.span_events"), "{error}");
346        assert!(error.contains("enter"), "{error}");
347        assert!(error.contains("close"), "{error}");
348    }
349
350    /// `prepare_logging` validates everything before building anything, so each
351    /// bad key is reported by name — which is what makes a reload carrying one
352    /// a refusal with the message startup would have printed, rather than a
353    /// half-swapped stack. `init_logging` funnels through it, so the failure
354    /// path below covers both.
355    #[test]
356    fn prepare_logging_reports_each_bad_key_by_name() {
357        for (logging, expected) in bad_key_cases() {
358            let _guard = crate::config::ENV_LOCK
359                .lock()
360                .unwrap_or_else(|e| e.into_inner());
361            unsafe { std::env::remove_var("RUST_LOG") };
362
363            // Not `expect_err`: the `Ok` side holds a boxed `Layer`, which has
364            // no `Debug` to print.
365            let Err(error) = prepare_logging(&logging) else {
366                panic!("`{expected}` must be refused, not built");
367            };
368            assert!(error.contains(expected), "{error}");
369        }
370    }
371
372    /// Publishing with no subscriber installed is a documented no-op rather
373    /// than a panic or a lie: this process never called `init_logging`, so
374    /// logging is not ours to swap. The `false` is what
375    /// `ReloadReport::logging_reloaded` carries, so an operator is told.
376    #[test]
377    fn publishing_without_an_installed_subscriber_is_a_no_op() {
378        let _guard = crate::config::ENV_LOCK
379            .lock()
380            .unwrap_or_else(|e| e.into_inner());
381        unsafe { std::env::remove_var("RUST_LOG") };
382
383        let prepared =
384            prepare_logging(&crate::config::LoggingConfig::default()).expect("the defaults build");
385        assert!(!publish_logging(prepared));
386    }
387
388    /// The swap really changes what is enabled, which is the whole feature.
389    ///
390    /// `LevelFilter::current()` is the static maximum `tracing` consults before
391    /// it reaches any subscriber, so asserting it moved is what proves
392    /// `Handle::reload` rebuilt the interest cache rather than merely storing a
393    /// new layer nothing asks. Its own process, like the two installers below.
394    #[test]
395    fn a_reloaded_filter_changes_what_is_enabled() {
396        let _guard = crate::config::ENV_LOCK
397            .lock()
398            .unwrap_or_else(|e| e.into_inner());
399        unsafe { std::env::remove_var("RUST_LOG") };
400
401        let at_info = crate::config::LoggingConfig {
402            filter: "acme_proxy=info".to_string(),
403            target: "stderr".to_string(),
404            ..Default::default()
405        };
406        init_logging(&at_info).expect("the subscriber installs");
407        assert_eq!(LevelFilter::current(), LevelFilter::INFO);
408        assert!(!tracing::enabled!(target: "acme_proxy", tracing::Level::DEBUG));
409
410        let at_debug = crate::config::LoggingConfig {
411            filter: "acme_proxy=debug".to_string(),
412            target: "stderr".to_string(),
413            ..Default::default()
414        };
415        let prepared = prepare_logging(&at_debug).expect("the debug filter builds");
416        assert!(publish_logging(prepared), "the handle is installed");
417
418        assert_eq!(LevelFilter::current(), LevelFilter::DEBUG);
419        assert!(tracing::enabled!(target: "acme_proxy", tracing::Level::DEBUG));
420    }
421
422    /// The other five keys change the stack's *shape*, which is why the whole
423    /// layer is boxed behind one handle rather than only the filter being
424    /// reloadable. Human-readable to JSON is the biggest such change there is.
425    #[test]
426    fn a_reloaded_format_swaps_the_whole_stack() {
427        let _guard = crate::config::ENV_LOCK
428            .lock()
429            .unwrap_or_else(|e| e.into_inner());
430        unsafe { std::env::remove_var("RUST_LOG") };
431
432        init_logging(&crate::config::LoggingConfig {
433            target: "stderr".to_string(),
434            ansi: false,
435            ..Default::default()
436        })
437        .expect("the subscriber installs");
438
439        let as_json = crate::config::LoggingConfig {
440            json_format: true,
441            flatten_event: true,
442            target: "stderr".to_string(),
443            span_events: "close".to_string(),
444            ..Default::default()
445        };
446        let prepared = prepare_logging(&as_json).expect("the JSON stack builds");
447        assert!(publish_logging(prepared));
448
449        // The filter has to survive the shape change: `and_then` puts it on the
450        // outside precisely so `Layered::max_level_hint` keeps reading it, and
451        // building the JSON arm the readable way round would silently drop it
452        // to `TRACE` here.
453        assert_eq!(
454            LevelFilter::current(),
455            LevelFilter::INFO,
456            "swapping the format must not lose the filter's level hint",
457        );
458    }
459
460    /// The three keys whose value can be wrong, and the name each must be
461    /// refused by. Shared so `init_logging` and `prepare_logging` cannot drift
462    /// on which of them they check.
463    fn bad_key_cases() -> Vec<(crate::config::LoggingConfig, &'static str)> {
464        vec![
465            (
466                crate::config::LoggingConfig {
467                    filter: "not=a=filter".to_string(),
468                    ..Default::default()
469                },
470                "logging.filter",
471            ),
472            (
473                crate::config::LoggingConfig {
474                    target: "nowhere".to_string(),
475                    ..Default::default()
476                },
477                "logging.target",
478            ),
479            (
480                crate::config::LoggingConfig {
481                    span_events: "sometimes".to_string(),
482                    ..Default::default()
483                },
484                "logging.span_events",
485            ),
486        ]
487    }
488
489    /// `init_logging` validates everything before installing anything, so each
490    /// bad key is reported by name. Only the failure path is driven here:
491    /// installing a subscriber is process-wide and would leak into every other
492    /// test in this binary.
493    #[test]
494    fn init_logging_reports_each_bad_key_by_name() {
495        for (logging, expected) in bad_key_cases() {
496            // `RUST_LOG` wins over `logging.filter`, so the filter case is only
497            // reachable with it unset — which the crate-wide lock guarantees.
498            let _guard = crate::config::ENV_LOCK
499                .lock()
500                .unwrap_or_else(|e| e.into_inner());
501            unsafe { std::env::remove_var("RUST_LOG") };
502
503            let error = init_logging(&logging).unwrap_err();
504            assert!(error.contains(expected), "{error}");
505        }
506    }
507
508    /// The two arms that actually install a subscriber, one per test.
509    ///
510    /// `init()` panics on a second call, so these would be untestable under
511    /// plain `cargo test` — one process, every test a thread. nextest runs each
512    /// test as its own process, which is what makes installing a *global*
513    /// subscriber a thing a test can do at all. (The suite already requires
514    /// nextest for an unrelated reason; see the Testing notes.)
515    #[test]
516    fn the_human_readable_subscriber_installs() {
517        let logging = crate::config::LoggingConfig {
518            target: "stderr".to_string(),
519            ansi: false,
520            span_events: "close".to_string(),
521            ..Default::default()
522        };
523        assert!(init_logging(&logging).is_ok());
524    }
525
526    #[test]
527    fn the_json_subscriber_installs() {
528        let logging = crate::config::LoggingConfig {
529            json_format: true,
530            flatten_event: true,
531            span_events: "full".to_string(),
532            ..Default::default()
533        };
534        assert!(init_logging(&logging).is_ok());
535    }
536}