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}