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`]
6//! `src/main.rs` makes.
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//! # Who gets a subscriber
15//!
16//! `[logging]` describes the **server's** log stream, and until this module
17//! grew [`plan_logging`] every subcommand got it: with the shipped defaults
18//! (`acme_proxy=info`, to **stdout**) `acme-proxy account list --json | jq`
19//! read a `db_migration_completed` record before the JSON, and `filter
20//! explain` wrote a `warn` into the middle of the explanation it was printing.
21//!
22//! So the decision is now made per invocation, by a pure function a test can
23//! drive rather than in `main.rs`, which the coverage floor excludes:
24//!
25//! - `serve` gets the stack `[logging]` describes, exactly as before.
26//! - **Any other subcommand emits nothing at all** unless the operator asks —
27//! with `--log-level`, or with a non-empty `RUST_LOG`. There is no subscriber
28//! in that case, so every `tracing` call in the process is a no-op rather
29//! than a filtered one.
30//! - When one does ask, the records go to **stderr** whatever `logging.target`
31//! says, because stdout is the answer the operator's `jq` or `awk` is reading
32//! and a diagnostic does not belong in it.
33//!
34//! [`LogLevel`] outranks both `RUST_LOG` and `logging.filter`, which is
35//! [`super::style`]'s argument for `--color always` outranking `NO_COLOR`: a
36//! flag was typed on this command line where the other two are ambient. See
37//! [`FilterSource`], which travels with the filter so a reload can say which of
38//! the three won.
39//!
40//! # Reloading
41//!
42//! All six keys reload on `SIGHUP`, which is why the whole stack is built as one
43//! [`Installed`] layer behind a [`tracing_subscriber::reload::Layer`] rather
44//! than through the `tracing_subscriber::fmt()` builder. Three things that shape
45//! rests on, each a bug if reversed:
46//!
47//! - **The filter is composed with [`Layer::and_then`], never `with_filter`.**
48//! `reload::Handle::reload` is documented as unusable with a
49//! [`tracing_subscriber::filter::Filtered`] layer (tokio-rs/tracing#1629),
50//! because replacing it mints a filter id the registry never saw. `and_then`
51//! is global filtering — `Layered::enabled` is the conjunction of both halves
52//! — which is exactly the semantics `with_env_filter` gave before.
53//! - **One boxed layer, not two.** `Box<dyn Layer<S>>` has to name its `S`, and
54//! a second `.with()` makes the next layer's `S` the `Layered<…>` of the
55//! first — a type nothing can write down in a `static`. One box is also one
56//! lock rather than two.
57//! - **The handle lives in a process-wide [`OnceLock`], beside the global it is
58//! a handle to.** The subscriber already is process-global (`.init()` panics
59//! on a second call); this is not a second one. Threading the handle from
60//! `main.rs` to `cli::apply_reload` instead would touch six signatures,
61//! including the `serve_on*` seams every test enters through. When it is
62//! unset — a test binary, or a consumer that installed its own subscriber —
63//! [`publish_logging`] is a **no-op that says so**, since logging is then not
64//! ours to swap.
65//!
66//! The cost, stated rather than buried: a `reload::Layer` puts an `RwLock` read
67//! on every event.
68
69use std::sync::OnceLock;
70
71use clap::ValueEnum;
72use tracing_subscriber::fmt::format::FmtSpan;
73use tracing_subscriber::fmt::writer::BoxMakeWriter;
74use tracing_subscriber::layer::SubscriberExt;
75use tracing_subscriber::util::SubscriberInitExt;
76use tracing_subscriber::{EnvFilter, Layer, Registry, reload};
77
78/// The layer stack as one value, so a reload can replace all of it at once.
79///
80/// Boxed against `Registry` specifically: that is the base subscriber both
81/// [`init_logging`] and every swap build on.
82type Installed = Box<dyn Layer<Registry> + Send + Sync>;
83
84/// The handle to the installed stack, or unset when this process installed no
85/// subscriber of its own. See the reloading notes in the module doc.
86static RELOAD: OnceLock<reload::Handle<Installed, Registry>> = OnceLock::new();
87
88/// The `--log-level` directive this process was started with, or unset when the
89/// flag was not given.
90///
91/// Beside [`RELOAD`] and for its reason, stated in the module doc: a reload
92/// rebuilds the stack from the *file*, so without somewhere process-wide to
93/// read the flag back from, `SIGHUP` would silently drop it — and threading it
94/// down instead would touch the same six signatures, `serve_on*` included.
95/// Written once, after the value has been validated by building a filter from
96/// it, so a refused startup leaves nothing behind.
97static FILTER_OVERRIDE: OnceLock<String> = OnceLock::new();
98
99/// The `--log-level` directive to apply, or `None` when the flag was not given.
100///
101/// The one accessor for [`FILTER_OVERRIDE`], so a reload re-reads what startup
102/// was told rather than each caller reaching for the cell.
103pub(crate) fn flag_override() -> Option<&'static str> {
104 FILTER_OVERRIDE.get().map(String::as_str)
105}
106
107/// `--log-level`: how much this invocation logs.
108///
109/// A crate-local enum rather than [`tracing::Level`] for two reasons: `off` is
110/// not a level, and `clap`'s [`ValueEnum`] cannot be implemented for a foreign
111/// type anyway. Being a `value_enum` is also what puts the six values into the
112/// generated shell completions, which is what `--color` already buys.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
114pub enum LogLevel {
115 Off,
116 Error,
117 Warn,
118 Info,
119 Debug,
120 Trace,
121}
122
123impl LogLevel {
124 /// The `EnvFilter` directive this level asks for.
125 ///
126 /// **Scoped to this crate's own target**, so `--log-level debug` does not
127 /// also unleash `sqlx`, `hyper` and `rustls` on someone who wanted to see
128 /// why one command behaved oddly. `RUST_LOG` stays the way to write a
129 /// directive that reaches further — it is the same string this would have
130 /// to become, and a flag that took one would be a second spelling of it.
131 pub(crate) fn directive(self) -> String {
132 match self {
133 // Not `acme_proxy=off`: a per-target directive at `off` still
134 // leaves every *other* target at the default level, so the one
135 // value asking for silence would be the one that did not deliver
136 // it.
137 Self::Off => "off".to_string(),
138 Self::Error => "acme_proxy=error".to_string(),
139 Self::Warn => "acme_proxy=warn".to_string(),
140 Self::Info => "acme_proxy=info".to_string(),
141 Self::Debug => "acme_proxy=debug".to_string(),
142 Self::Trace => "acme_proxy=trace".to_string(),
143 }
144 }
145}
146
147/// Which subscriber, if any, an invocation installs.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum LoggingPlan {
150 /// Install nothing. Every `tracing` call in the process is then a no-op,
151 /// which is stronger — and cheaper — than a subscriber filtering them all
152 /// out.
153 Silent,
154 /// Install the stack `[logging]` describes: the server's own log stream.
155 Server,
156 /// Install a diagnostic stack on stderr for a one-shot admin command.
157 Command,
158}
159
160/// Decides what this invocation logs, from the subcommand and the two ways an
161/// operator can ask.
162///
163/// Pure, and here rather than in `src/main.rs` because that file is excluded
164/// from the coverage floor: every rule below is a row of a table test.
165///
166/// `rust_log` counts only when **non-empty**, which is
167/// [`super::style::no_color_set`]'s judgement applied to the other ambient
168/// environment variable this CLI reads. A `RUST_LOG=` left behind by a
169/// `${RUST_LOG:-}`-style shell default is not somebody asking for logs, and
170/// treating it as one would put records back in the pipe this exists to keep
171/// clean.
172pub fn plan_logging(
173 command: Option<&super::Command>,
174 level: Option<LogLevel>,
175 rust_log: Option<&str>,
176) -> LoggingPlan {
177 match command {
178 // A daemon logs; the flag only sharpens what it says. `None` is the
179 // default subcommand, i.e. a bare `acme-proxy`.
180 None | Some(super::Command::Serve) => LoggingPlan::Server,
181 // `main.rs` answers both before it loads a configuration, so neither
182 // reaches here — but answering them keeps this total over `Command`,
183 // the rule `dispatch` follows for the same pair.
184 Some(super::Command::Completions { .. } | super::Command::Man) => LoggingPlan::Silent,
185 Some(_) => {
186 if level.is_some() || rust_log.is_some_and(|value| !value.is_empty()) {
187 LoggingPlan::Command
188 } else {
189 LoggingPlan::Silent
190 }
191 }
192 }
193}
194
195/// Which of the three layers supplied the filter in force.
196///
197/// The provenance travels with the filter because it decides whether an
198/// operator is owed a warning: with either of the two outranking layers in
199/// play, editing `logging.filter` and reloading changes nothing at all, and a
200/// silent no-op is the one outcome worth a line in the log. It is also the
201/// `source` field on that warning, so the operator is told *which* to unset.
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub(crate) enum FilterSource {
204 /// `--log-level`, typed on this command line.
205 Flag,
206 /// `RUST_LOG`, from the environment.
207 Env,
208 /// `logging.filter`, from the configuration file.
209 Config,
210}
211
212impl FilterSource {
213 /// The `source` field's value on `server_logging_filter_overridden`.
214 pub(crate) fn as_str(self) -> &'static str {
215 match self {
216 Self::Flag => "flag",
217 Self::Env => "env",
218 Self::Config => "config",
219 }
220 }
221
222 /// Whether `logging.filter` was overruled, i.e. whether editing it and
223 /// reloading would change nothing.
224 pub(crate) fn outranks_config(self) -> bool {
225 !matches!(self, Self::Config)
226 }
227}
228
229/// The tracing filter, and where it came from.
230#[derive(Debug)]
231struct ResolvedFilter {
232 filter: EnvFilter,
233 source: FilterSource,
234}
235
236/// Builds the tracing filter: `flag` if given, else `RUST_LOG` if set and
237/// valid, else `logging.filter`.
238///
239/// Returns an error rather than unwrapping. `logging.filter` is
240/// operator-supplied and environment-overridable, so a typo in it used to
241/// panic the process with a backtrace — four lines after a configuration error
242/// was handled cleanly with a message and an exit code. Exiting rather than
243/// silently falling back to a default is the deliberate half: a certificate
244/// authority quietly running at a different log level than its operator asked
245/// for is worse than one that refuses to start and says why.
246///
247/// `flag` outranks `RUST_LOG` for [`super::style`]'s reason: it was typed on
248/// this command line, where the environment is ambient. It is a [`LogLevel`]
249/// rendering rather than operator text, so it cannot fail to parse — but it
250/// goes through the same `try_new` as the other two rather than being trusted,
251/// since a value that cannot fail is one nobody notices becoming able to.
252///
253/// The precedence is the same on a reload as at startup, and deliberately: the
254/// two disagreeing about what the server is running would be worse than the
255/// override itself.
256fn build_env_filter(
257 logging: &crate::config::LoggingConfig,
258 flag: Option<&str>,
259) -> Result<ResolvedFilter, String> {
260 if let Some(directive) = flag {
261 return EnvFilter::try_new(directive)
262 .map(|filter| ResolvedFilter {
263 filter,
264 source: FilterSource::Flag,
265 })
266 .map_err(|error| {
267 format!("--log-level `{directive}` is not a valid tracing filter: {error}")
268 });
269 }
270 if let Ok(filter) = EnvFilter::try_from_default_env() {
271 return Ok(ResolvedFilter {
272 filter,
273 source: FilterSource::Env,
274 });
275 }
276 EnvFilter::try_new(&logging.filter)
277 .map(|filter| ResolvedFilter {
278 filter,
279 source: FilterSource::Config,
280 })
281 .map_err(|error| {
282 format!(
283 "configuration error: logging.filter `{}` is not a valid tracing filter: {error}",
284 logging.filter
285 )
286 })
287}
288
289/// Resolves `logging.target` to the writer records are sent to.
290///
291/// Boxed so both values leave this function as one type — the two `fmt`
292/// builder chains below are already split by `json_format`, and splitting them
293/// again by writer would be four arms saying the same thing.
294fn parse_target(target: &str) -> Result<BoxMakeWriter, String> {
295 match target {
296 "stdout" => Ok(BoxMakeWriter::new(std::io::stdout)),
297 "stderr" => Ok(BoxMakeWriter::new(std::io::stderr)),
298 other => Err(format!(
299 "configuration error: logging.target `{other}` is not a known target (stdout, stderr)"
300 )),
301 }
302}
303
304/// Resolves `logging.span_events` to the span lifecycle records emitted.
305///
306/// `close` is the one worth reaching for: it emits a record as each span ends,
307/// carrying the time spent busy and idle inside it — per-request timing without
308/// a metrics endpoint. `full` adds `new`/`enter`/`exit` and is a debugging tool,
309/// not something to run a server on.
310fn parse_span_events(span_events: &str) -> Result<FmtSpan, String> {
311 match span_events {
312 "none" => Ok(FmtSpan::NONE),
313 "close" => Ok(FmtSpan::CLOSE),
314 "full" => Ok(FmtSpan::FULL),
315 other => Err(format!(
316 "configuration error: logging.span_events `{other}` is not a known value (none, close, full)"
317 )),
318 }
319}
320
321/// Whether to colour the human-readable format: `logging.ansi`, with `NO_COLOR`
322/// able to veto it.
323///
324/// `tracing-subscriber` honours `NO_COLOR` in its own default, and calling
325/// `with_ansi` at all replaces that default outright — so configuring this key
326/// naively would have silently broken the convention for every operator who
327/// relies on it. Either switch turns colour off; neither can turn it on against
328/// the other.
329///
330/// Per the convention, `NO_COLOR` counts only when set to a non-empty value —
331/// which is [`super::style::no_color_set`]'s judgement, shared with the admin
332/// CLI's own `--color` so the two answers cannot drift. Note the *precedence*
333/// deliberately does not match: a `--color always` outranks `NO_COLOR` where
334/// this key cannot, because a flag is typed and a configuration file is
335/// ambient. [`super::style`]'s module doc has the argument.
336fn ansi_enabled(configured: bool, no_color: Option<&str>) -> bool {
337 configured && !super::style::no_color_set(no_color)
338}
339
340/// A layer stack built from `[logging]` but not yet installed.
341///
342/// The build/publish split is [`crate::Assembly::build_dispatchers`] and
343/// `publish_notifiers`', for the same reason: a reload must be able to fail
344/// *after* building this and still leave the running configuration untouched.
345pub(crate) struct PreparedLogging {
346 layer: Installed,
347 /// Which of the three layers the filter came from, and so whether
348 /// `logging.filter` had any say.
349 pub(crate) filter_source: FilterSource,
350}
351
352/// Resolves `[logging]` into a layer stack, validating every key.
353///
354/// The one place a stack is built, so startup and a reload cannot drift — the
355/// reasoning behind [`super::build_generation`], applied to one layer.
356pub(crate) fn prepare_logging(
357 logging: &crate::config::LoggingConfig,
358 flag: Option<&str>,
359) -> Result<PreparedLogging, String> {
360 let ResolvedFilter { filter, source } = build_env_filter(logging, flag)?;
361 let writer = parse_target(&logging.target)?;
362 let span_events = parse_span_events(&logging.span_events)?;
363 let ansi = ansi_enabled(logging.ansi, std::env::var("NO_COLOR").ok().as_deref());
364
365 // The two arms are separate because `.json()` changes the layer's type, not
366 // because they differ in what they configure.
367 //
368 // **`format.and_then(filter)`, never the other way round.** `and_then`
369 // makes its argument the *outer* layer, and `Layered::max_level_hint`
370 // directly over a `Registry` returns the outer hint alone — so composing
371 // them the readable way round hands the format layer's `None` to
372 // `LevelFilter::current()`, which then sits at `TRACE` for the life of the
373 // process. Every record would still be filtered correctly by `enabled`, so
374 // nothing would look wrong; the cost is that `tracing`'s static
375 // short-circuit stops working and every disabled callsite in the tree pays
376 // a subscriber call.
377 let layer: Installed = if logging.json_format {
378 tracing_subscriber::fmt::layer()
379 .json()
380 .flatten_event(logging.flatten_event)
381 .with_span_events(span_events)
382 .with_writer(writer)
383 .and_then(filter)
384 .boxed()
385 } else {
386 tracing_subscriber::fmt::layer()
387 .with_ansi(ansi)
388 .with_span_events(span_events)
389 .with_writer(writer)
390 .and_then(filter)
391 .boxed()
392 };
393
394 Ok(PreparedLogging {
395 layer,
396 filter_source: source,
397 })
398}
399
400/// Makes `prepared` the stack every later record goes through, reporting
401/// whether it took.
402///
403/// `false` means this process installed no subscriber of its own, so there is
404/// no handle and nothing was swapped — see the module doc. Synchronous, which
405/// is what lets it sit inside `cli::apply_reload`'s publishing run beside the
406/// `watch` sends.
407pub(crate) fn publish_logging(prepared: PreparedLogging) -> bool {
408 RELOAD
409 .get()
410 .is_some_and(|handle| handle.reload(prepared.layer).is_ok())
411}
412
413/// Installs the process-wide tracing subscriber from `[logging]`.
414///
415/// Every knob is validated before anything is installed, and an unknown value
416/// is an error the caller prints and exits on rather than a silent fallback —
417/// the same reasoning as [`build_env_filter`]: a certificate authority running
418/// at a log level or to a destination its operator did not ask for is worse
419/// than one that refuses to start and says why.
420pub fn init_logging(
421 logging: &crate::config::LoggingConfig,
422 level: Option<LogLevel>,
423) -> Result<(), String> {
424 let directive = level.map(LogLevel::directive);
425 let prepared = prepare_logging(logging, directive.as_deref())?;
426 let (layer, handle) = reload::Layer::new(prepared.layer);
427 tracing_subscriber::registry().with(layer).init();
428 // A second install would already have panicked in `init()` above, so the
429 // only way this loses the race is a caller that never got that far.
430 let _ = RELOAD.set(handle);
431 // Stored only now: a directive that would not build must leave the cell
432 // unset, or a refused startup would hand a reload a filter nothing ever
433 // installed.
434 if let Some(directive) = directive {
435 let _ = FILTER_OVERRIDE.set(directive);
436 }
437 Ok(())
438}
439
440/// The stack a one-shot admin command logs through, when it logs at all.
441///
442/// **`stderr`, whatever `logging.target` says**, and `logging.filter` is not
443/// consulted either: that section describes the *server's* log stream, while
444/// this is a diagnostic an operator asked one command for. stdout is the answer
445/// — the rows, or the `--json` document — and putting a record in it is what
446/// this whole path exists to stop.
447///
448/// Human-readable rather than `logging.json_format`'s shape for the same
449/// reason: the audience is the terminal the command was typed into. `NO_COLOR`
450/// still vetoes the colour, through the shared [`ansi_enabled`].
451fn command_logging_config() -> crate::config::LoggingConfig {
452 crate::config::LoggingConfig {
453 target: "stderr".to_string(),
454 // The compiled default, deliberately, and not the operator's own
455 // `logging.filter`. It is reached only when `--log-level` was not
456 // given, i.e. when a non-empty `RUST_LOG` is what asked — and that
457 // outranks it, so this is a fallback nothing normally reads.
458 ..crate::config::LoggingConfig::default()
459 }
460}
461
462/// Installs the diagnostic subscriber a one-shot admin command asked for.
463///
464/// `level` is `None` when a non-empty `RUST_LOG` is what asked, in which case
465/// it supplies the filter through [`build_env_filter`]'s second layer — see
466/// [`plan_logging`], which is what decides this function is called at all.
467pub fn init_command_logging(level: Option<LogLevel>) -> Result<(), String> {
468 init_logging(&command_logging_config(), level)
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use tracing::level_filters::LevelFilter;
475
476 /// A malformed `logging.filter` is an error a caller can print, not a
477 /// panic. It is operator-supplied and environment-overridable, so a typo
478 /// used to take the process down with a backtrace — four lines after a
479 /// configuration error was handled cleanly.
480 #[test]
481 fn a_malformed_logging_filter_is_reported_rather_than_panicking() {
482 let logging = crate::config::LoggingConfig {
483 filter: "this is not=a=valid=filter".to_string(),
484 ..Default::default()
485 };
486 let error = build_env_filter(&logging, None).unwrap_err();
487 assert!(error.contains("logging.filter"), "{error}");
488 assert!(error.contains("this is not=a=valid=filter"), "{error}");
489 }
490
491 #[test]
492 fn a_valid_logging_filter_builds() {
493 let _guard = crate::config::ENV_LOCK
494 .lock()
495 .unwrap_or_else(|e| e.into_inner());
496 unsafe { std::env::remove_var("RUST_LOG") };
497
498 let logging = crate::config::LoggingConfig {
499 filter: "acme_proxy=debug".to_string(),
500 ..Default::default()
501 };
502 let resolved = build_env_filter(&logging, None).expect("a valid filter builds");
503 assert_eq!(
504 resolved.source,
505 FilterSource::Config,
506 "with RUST_LOG unset and no flag the filter comes from `logging.filter`",
507 );
508 }
509
510 /// The provenance the reload path warns off: `RUST_LOG` wins, so an edited
511 /// `logging.filter` would change nothing.
512 #[test]
513 fn rust_log_wins_and_says_so() {
514 let _guard = crate::config::ENV_LOCK
515 .lock()
516 .unwrap_or_else(|e| e.into_inner());
517 unsafe { std::env::set_var("RUST_LOG", "acme_proxy=warn") };
518
519 let logging = crate::config::LoggingConfig {
520 filter: "acme_proxy=trace".to_string(),
521 ..Default::default()
522 };
523 let resolved = build_env_filter(&logging, None).expect("RUST_LOG parses");
524 assert_eq!(resolved.source, FilterSource::Env);
525
526 unsafe { std::env::remove_var("RUST_LOG") };
527 }
528
529 /// `NO_COLOR` must keep working: `tracing-subscriber` honours it in the
530 /// default that `with_ansi` replaces, so configuring the key at all is what
531 /// put the convention at risk.
532 #[test]
533 fn no_color_vetoes_ansi_and_an_empty_value_does_not() {
534 assert!(ansi_enabled(true, None));
535 assert!(!ansi_enabled(true, Some("1")));
536 assert!(!ansi_enabled(true, Some("anything")));
537 // The convention counts only a non-empty value.
538 assert!(ansi_enabled(true, Some("")));
539 // Configured off stays off however NO_COLOR is set.
540 assert!(!ansi_enabled(false, None));
541 assert!(!ansi_enabled(false, Some("1")));
542 }
543
544 #[test]
545 fn both_logging_targets_resolve() {
546 assert!(parse_target("stdout").is_ok());
547 assert!(parse_target("stderr").is_ok());
548 }
549
550 /// A typo'd target must stop the process, not quietly pick one: an operator
551 /// who asked for `stderr` and silently got `stdout` would look for the log
552 /// in the wrong stream.
553 #[test]
554 fn an_unknown_logging_target_is_reported() {
555 let error = parse_target("syslog").unwrap_err();
556 assert!(error.contains("logging.target"), "{error}");
557 assert!(error.contains("syslog"), "{error}");
558 assert!(error.contains("stdout"), "{error}");
559 }
560
561 #[test]
562 fn every_span_events_value_resolves() {
563 assert_eq!(parse_span_events("none").unwrap(), FmtSpan::NONE);
564 assert_eq!(parse_span_events("close").unwrap(), FmtSpan::CLOSE);
565 assert_eq!(parse_span_events("full").unwrap(), FmtSpan::FULL);
566 }
567
568 #[test]
569 fn an_unknown_span_events_value_is_reported() {
570 let error = parse_span_events("enter").unwrap_err();
571 assert!(error.contains("logging.span_events"), "{error}");
572 assert!(error.contains("enter"), "{error}");
573 assert!(error.contains("close"), "{error}");
574 }
575
576 /// `prepare_logging` validates everything before building anything, so each
577 /// bad key is reported by name — which is what makes a reload carrying one
578 /// a refusal with the message startup would have printed, rather than a
579 /// half-swapped stack. `init_logging` funnels through it, so the failure
580 /// path below covers both.
581 #[test]
582 fn prepare_logging_reports_each_bad_key_by_name() {
583 for (logging, expected) in bad_key_cases() {
584 let _guard = crate::config::ENV_LOCK
585 .lock()
586 .unwrap_or_else(|e| e.into_inner());
587 unsafe { std::env::remove_var("RUST_LOG") };
588
589 // Not `expect_err`: the `Ok` side holds a boxed `Layer`, which has
590 // no `Debug` to print.
591 let Err(error) = prepare_logging(&logging, None) else {
592 panic!("`{expected}` must be refused, not built");
593 };
594 assert!(error.contains(expected), "{error}");
595 }
596 }
597
598 /// Publishing with no subscriber installed is a documented no-op rather
599 /// than a panic or a lie: this process never called `init_logging`, so
600 /// logging is not ours to swap. The `false` is what
601 /// `ReloadReport::logging_reloaded` carries, so an operator is told.
602 #[test]
603 fn publishing_without_an_installed_subscriber_is_a_no_op() {
604 let _guard = crate::config::ENV_LOCK
605 .lock()
606 .unwrap_or_else(|e| e.into_inner());
607 unsafe { std::env::remove_var("RUST_LOG") };
608
609 let prepared = prepare_logging(&crate::config::LoggingConfig::default(), None)
610 .expect("the defaults build");
611 assert!(!publish_logging(prepared));
612 }
613
614 /// The swap really changes what is enabled, which is the whole feature.
615 ///
616 /// `LevelFilter::current()` is the static maximum `tracing` consults before
617 /// it reaches any subscriber, so asserting it moved is what proves
618 /// `Handle::reload` rebuilt the interest cache rather than merely storing a
619 /// new layer nothing asks. Its own process, like the two installers below.
620 #[test]
621 fn a_reloaded_filter_changes_what_is_enabled() {
622 let _guard = crate::config::ENV_LOCK
623 .lock()
624 .unwrap_or_else(|e| e.into_inner());
625 unsafe { std::env::remove_var("RUST_LOG") };
626
627 let at_info = crate::config::LoggingConfig {
628 filter: "acme_proxy=info".to_string(),
629 target: "stderr".to_string(),
630 ..Default::default()
631 };
632 init_logging(&at_info, None).expect("the subscriber installs");
633 assert_eq!(LevelFilter::current(), LevelFilter::INFO);
634 assert!(!tracing::enabled!(target: "acme_proxy", tracing::Level::DEBUG));
635
636 let at_debug = crate::config::LoggingConfig {
637 filter: "acme_proxy=debug".to_string(),
638 target: "stderr".to_string(),
639 ..Default::default()
640 };
641 let prepared = prepare_logging(&at_debug, None).expect("the debug filter builds");
642 assert!(publish_logging(prepared), "the handle is installed");
643
644 assert_eq!(LevelFilter::current(), LevelFilter::DEBUG);
645 assert!(tracing::enabled!(target: "acme_proxy", tracing::Level::DEBUG));
646 }
647
648 /// The other five keys change the stack's *shape*, which is why the whole
649 /// layer is boxed behind one handle rather than only the filter being
650 /// reloadable. Human-readable to JSON is the biggest such change there is.
651 #[test]
652 fn a_reloaded_format_swaps_the_whole_stack() {
653 let _guard = crate::config::ENV_LOCK
654 .lock()
655 .unwrap_or_else(|e| e.into_inner());
656 unsafe { std::env::remove_var("RUST_LOG") };
657
658 init_logging(
659 &crate::config::LoggingConfig {
660 target: "stderr".to_string(),
661 ansi: false,
662 ..Default::default()
663 },
664 None,
665 )
666 .expect("the subscriber installs");
667
668 let as_json = crate::config::LoggingConfig {
669 json_format: true,
670 flatten_event: true,
671 target: "stderr".to_string(),
672 span_events: "close".to_string(),
673 ..Default::default()
674 };
675 let prepared = prepare_logging(&as_json, None).expect("the JSON stack builds");
676 assert!(publish_logging(prepared));
677
678 // The filter has to survive the shape change: `and_then` puts it on the
679 // outside precisely so `Layered::max_level_hint` keeps reading it, and
680 // building the JSON arm the readable way round would silently drop it
681 // to `TRACE` here.
682 assert_eq!(
683 LevelFilter::current(),
684 LevelFilter::INFO,
685 "swapping the format must not lose the filter's level hint",
686 );
687 }
688
689 /// The three keys whose value can be wrong, and the name each must be
690 /// refused by. Shared so `init_logging` and `prepare_logging` cannot drift
691 /// on which of them they check.
692 fn bad_key_cases() -> Vec<(crate::config::LoggingConfig, &'static str)> {
693 vec![
694 (
695 crate::config::LoggingConfig {
696 filter: "not=a=filter".to_string(),
697 ..Default::default()
698 },
699 "logging.filter",
700 ),
701 (
702 crate::config::LoggingConfig {
703 target: "nowhere".to_string(),
704 ..Default::default()
705 },
706 "logging.target",
707 ),
708 (
709 crate::config::LoggingConfig {
710 span_events: "sometimes".to_string(),
711 ..Default::default()
712 },
713 "logging.span_events",
714 ),
715 ]
716 }
717
718 /// `init_logging` validates everything before installing anything, so each
719 /// bad key is reported by name. Only the failure path is driven here:
720 /// installing a subscriber is process-wide and would leak into every other
721 /// test in this binary.
722 #[test]
723 fn init_logging_reports_each_bad_key_by_name() {
724 for (logging, expected) in bad_key_cases() {
725 // `RUST_LOG` wins over `logging.filter`, so the filter case is only
726 // reachable with it unset — which the crate-wide lock guarantees.
727 let _guard = crate::config::ENV_LOCK
728 .lock()
729 .unwrap_or_else(|e| e.into_inner());
730 unsafe { std::env::remove_var("RUST_LOG") };
731
732 let error = init_logging(&logging, None).unwrap_err();
733 assert!(error.contains(expected), "{error}");
734 }
735 }
736
737 /// The two arms that actually install a subscriber, one per test.
738 ///
739 /// `init()` panics on a second call, so these would be untestable under
740 /// plain `cargo test` — one process, every test a thread. nextest runs each
741 /// test as its own process, which is what makes installing a *global*
742 /// subscriber a thing a test can do at all. (The suite already requires
743 /// nextest for an unrelated reason; see the Testing notes.)
744 #[test]
745 fn the_human_readable_subscriber_installs() {
746 let logging = crate::config::LoggingConfig {
747 target: "stderr".to_string(),
748 ansi: false,
749 span_events: "close".to_string(),
750 ..Default::default()
751 };
752 assert!(init_logging(&logging, None).is_ok());
753 }
754
755 #[test]
756 fn the_json_subscriber_installs() {
757 let logging = crate::config::LoggingConfig {
758 json_format: true,
759 flatten_event: true,
760 span_events: "full".to_string(),
761 ..Default::default()
762 };
763 assert!(init_logging(&logging, None).is_ok());
764 }
765
766 /// One row of the `plan_logging` table: a command line, the flag, the
767 /// environment, and what the three of them must decide.
768 type PlanCase<'a> = (
769 &'a [&'a str],
770 Option<LogLevel>,
771 Option<&'a str>,
772 LoggingPlan,
773 );
774
775 /// Parsed rather than hand-built: naming every subcommand enum here would
776 /// be a second copy of the clap tree, and what has to be right is what a
777 /// real command line resolves to.
778 fn command_of(argv: &[&str]) -> Option<super::super::Command> {
779 use clap::Parser;
780 super::super::Cli::try_parse_from(argv)
781 .expect("the fixture command line parses")
782 .command
783 }
784
785 /// The whole point of the flag, as a table: **an admin command is silent
786 /// unless somebody asked**, `serve` never is, and `RUST_LOG` counts only
787 /// when it holds something.
788 #[test]
789 fn plan_logging_decides_who_gets_a_subscriber() {
790 let cases: Vec<PlanCase> = vec![
791 // A daemon logs, however it was reached and whatever is unset.
792 (&["acme-proxy"], None, None, LoggingPlan::Server),
793 (&["acme-proxy", "serve"], None, None, LoggingPlan::Server),
794 (
795 &["acme-proxy", "serve"],
796 Some(LogLevel::Debug),
797 None,
798 LoggingPlan::Server,
799 ),
800 // The reported bug: these used to write `db_migration_completed`
801 // into the operator's `jq` pipe.
802 (
803 &["acme-proxy", "account", "list"],
804 None,
805 None,
806 LoggingPlan::Silent,
807 ),
808 (
809 &["acme-proxy", "filter", "show"],
810 None,
811 None,
812 LoggingPlan::Silent,
813 ),
814 (
815 &["acme-proxy", "audit", "list"],
816 None,
817 None,
818 LoggingPlan::Silent,
819 ),
820 (
821 &["acme-proxy", "admin", "user", "list"],
822 None,
823 None,
824 LoggingPlan::Silent,
825 ),
826 // Both ways of asking, and only those two.
827 (
828 &["acme-proxy", "account", "list"],
829 Some(LogLevel::Debug),
830 None,
831 LoggingPlan::Command,
832 ),
833 (
834 &["acme-proxy", "account", "list"],
835 Some(LogLevel::Off),
836 None,
837 LoggingPlan::Command,
838 ),
839 (
840 &["acme-proxy", "account", "list"],
841 None,
842 Some("acme_proxy=debug"),
843 LoggingPlan::Command,
844 ),
845 // A `${RUST_LOG:-}` shell default is present, not a request — the
846 // judgement `no_color_set` already makes about the other ambient
847 // variable this CLI reads.
848 (
849 &["acme-proxy", "account", "list"],
850 None,
851 Some(""),
852 LoggingPlan::Silent,
853 ),
854 // Answered by `main.rs` before any of this, but total here anyway.
855 (&["acme-proxy", "man"], None, None, LoggingPlan::Silent),
856 (
857 &["acme-proxy", "completions", "bash"],
858 Some(LogLevel::Trace),
859 Some("debug"),
860 LoggingPlan::Silent,
861 ),
862 ];
863
864 for (argv, level, rust_log, expected) in cases {
865 let command = command_of(argv);
866 let plan = plan_logging(command.as_ref(), level, rust_log);
867 assert_eq!(
868 plan,
869 expected,
870 "`{}` with level {level:?} and RUST_LOG {rust_log:?}",
871 argv.join(" "),
872 );
873 }
874 }
875
876 /// A flag was typed on this command line where both other layers are
877 /// ambient — `super::super::style`'s argument for `--color always`
878 /// outranking `NO_COLOR`, applied to the filter.
879 #[test]
880 fn the_flag_outranks_rust_log_and_the_file() {
881 let _guard = crate::config::ENV_LOCK
882 .lock()
883 .unwrap_or_else(|e| e.into_inner());
884 unsafe { std::env::set_var("RUST_LOG", "acme_proxy=warn") };
885
886 let logging = crate::config::LoggingConfig {
887 filter: "acme_proxy=error".to_string(),
888 ..Default::default()
889 };
890 let resolved = build_env_filter(&logging, Some(&LogLevel::Trace.directive()))
891 .expect("the flag's directive builds");
892 assert_eq!(resolved.source, FilterSource::Flag);
893 assert_eq!(
894 resolved.filter.to_string(),
895 "acme_proxy=trace",
896 "neither RUST_LOG nor logging.filter may have a say once the flag is given",
897 );
898
899 unsafe { std::env::remove_var("RUST_LOG") };
900 }
901
902 /// Each level's directive is **scoped to this crate**, so `--log-level
903 /// debug` does not also unleash `sqlx` and `hyper` on somebody debugging
904 /// one command. `off` is the exception and has to be: a per-target
905 /// directive at `off` leaves every other target at the default level, so
906 /// the one value asking for silence would be the one not delivering it.
907 #[test]
908 fn every_level_renders_a_directive_scoped_to_this_crate() {
909 assert_eq!(LogLevel::Off.directive(), "off");
910 for (level, expected) in [
911 (LogLevel::Error, "acme_proxy=error"),
912 (LogLevel::Warn, "acme_proxy=warn"),
913 (LogLevel::Info, "acme_proxy=info"),
914 (LogLevel::Debug, "acme_proxy=debug"),
915 (LogLevel::Trace, "acme_proxy=trace"),
916 ] {
917 assert_eq!(level.directive(), expected);
918 EnvFilter::try_new(level.directive()).expect("every directive parses");
919 }
920 EnvFilter::try_new(LogLevel::Off.directive()).expect("`off` parses");
921 }
922
923 /// stdout is the answer an admin command was run for — the rows, or the
924 /// `--json` document — so a record asked for with `--log-level` goes to
925 /// stderr whatever `logging.target` says. That key describes the server's
926 /// stream, and this path deliberately does not consult it.
927 #[test]
928 fn a_command_run_logs_to_stderr_whatever_logging_target_says() {
929 assert_eq!(
930 crate::config::LoggingConfig::default().target,
931 "stdout",
932 "the default this must not inherit",
933 );
934 let built = command_logging_config();
935 assert_eq!(built.target, "stderr");
936 assert!(!built.json_format, "the audience is a terminal");
937 }
938
939 /// The three `FilterSource`s, and the question the reload warning asks of
940 /// them. `config` is the only one that does *not* make an edited
941 /// `logging.filter` a no-op.
942 #[test]
943 fn only_the_two_outranking_sources_silence_an_edit() {
944 assert!(FilterSource::Flag.outranks_config());
945 assert!(FilterSource::Env.outranks_config());
946 assert!(!FilterSource::Config.outranks_config());
947 assert_eq!(FilterSource::Flag.as_str(), "flag");
948 assert_eq!(FilterSource::Env.as_str(), "env");
949 assert_eq!(FilterSource::Config.as_str(), "config");
950 }
951
952 /// The flag's own directives cannot fail to parse — they are renderings of
953 /// a closed enum — but the arm is built to refuse rather than to trust,
954 /// since a value that cannot be wrong is one nobody notices becoming able
955 /// to. Driven with a hand-written directive, which is the only way in.
956 #[test]
957 fn an_unparseable_flag_directive_is_refused_by_name() {
958 let error = build_env_filter(
959 &crate::config::LoggingConfig::default(),
960 Some("not=a=filter"),
961 )
962 .unwrap_err();
963 assert!(error.contains("--log-level"), "{error}");
964 assert!(error.contains("not=a=filter"), "{error}");
965 }
966
967 /// The whole admin-command path, installed: the flag's level in force and
968 /// the records on stderr. Its own process, like the three installers above.
969 #[test]
970 fn a_command_subscriber_installs_at_the_flags_level() {
971 let _guard = crate::config::ENV_LOCK
972 .lock()
973 .unwrap_or_else(|e| e.into_inner());
974 unsafe { std::env::remove_var("RUST_LOG") };
975
976 init_command_logging(Some(LogLevel::Warn)).expect("the subscriber installs");
977 assert_eq!(LevelFilter::current(), LevelFilter::WARN);
978 assert!(!tracing::enabled!(target: "acme_proxy", tracing::Level::INFO));
979 assert_eq!(flag_override(), Some("acme_proxy=warn"));
980 }
981
982 /// A `--log-level` typed at startup has to survive a `SIGHUP`: the stack is
983 /// rebuilt from the *file*, so without the process-wide cell the reload
984 /// would quietly demote the server to `logging.filter`. Its own process,
985 /// like the three installers above — it installs a global subscriber and
986 /// writes a `OnceLock`.
987 #[test]
988 fn the_flag_survives_a_reload() {
989 let _guard = crate::config::ENV_LOCK
990 .lock()
991 .unwrap_or_else(|e| e.into_inner());
992 unsafe { std::env::remove_var("RUST_LOG") };
993
994 assert!(flag_override().is_none(), "nothing is set before startup");
995
996 let at_error = crate::config::LoggingConfig {
997 filter: "acme_proxy=error".to_string(),
998 target: "stderr".to_string(),
999 ..Default::default()
1000 };
1001 init_logging(&at_error, Some(LogLevel::Debug)).expect("the subscriber installs");
1002 assert_eq!(LevelFilter::current(), LevelFilter::DEBUG);
1003 assert_eq!(flag_override(), Some("acme_proxy=debug"));
1004
1005 // The reload's own call, verbatim: a new file with a different filter,
1006 // rebuilt through the flag the cell remembers.
1007 let edited = crate::config::LoggingConfig {
1008 filter: "acme_proxy=error".to_string(),
1009 target: "stderr".to_string(),
1010 span_events: "close".to_string(),
1011 ..Default::default()
1012 };
1013 let prepared = prepare_logging(&edited, flag_override()).expect("the stack rebuilds");
1014 assert_eq!(prepared.filter_source, FilterSource::Flag);
1015 assert!(publish_logging(prepared));
1016 assert_eq!(
1017 LevelFilter::current(),
1018 LevelFilter::DEBUG,
1019 "the reload must not demote the server to the file's filter",
1020 );
1021 }
1022}