acme_proxy_server/logging.rs
1//! `[logging]` — resolving the configuration into an installed subscriber, and
2//! swapping it on a reload.
3//!
4//! The rule the whole module follows: **every knob is validated before anything
5//! is installed, and an unknown value is an error the caller prints and exits
6//! on rather than a silent fallback.** A certificate authority running at a log
7//! level or to a destination its operator did not ask for is worse than one
8//! that refuses to start and says why.
9//!
10//! Which invocation gets a subscriber at all, and the `--log-level` flag whose
11//! directive outranks `RUST_LOG` and `logging.filter`, are the CLI's
12//! (`cli::logging`); this module takes that directive as a string and records
13//! which of the three layers won in [`FilterSource`], so a reload can say.
14//!
15//! # Reloading
16//!
17//! All six keys reload on `SIGHUP`, which is why the whole stack is built as one
18//! [`Installed`] layer behind a [`tracing_subscriber::reload::Layer`] rather
19//! than through the `tracing_subscriber::fmt()` builder. Three things that shape
20//! rests on, each a bug if reversed:
21//!
22//! - **The filter is composed with [`Layer::and_then`], never `with_filter`.**
23//! `reload::Handle::reload` is documented as unusable with a
24//! [`tracing_subscriber::filter::Filtered`] layer (tokio-rs/tracing#1629),
25//! because replacing it mints a filter id the registry never saw. `and_then`
26//! is global filtering — `Layered::enabled` is the conjunction of both halves
27//! — which is exactly the semantics `with_env_filter` gave before.
28//! - **One boxed layer, not two.** `Box<dyn Layer<S>>` has to name its `S`, and
29//! a second `.with()` makes the next layer's `S` the `Layered<…>` of the
30//! first — a type nothing can write down in a `static`. One box is also one
31//! lock rather than two.
32//! - **The handle lives in a process-wide [`OnceLock`], beside the global it is
33//! a handle to.** The subscriber already is process-global (`.init()` panics
34//! on a second call); this is not a second one. Threading the handle from
35//! `main.rs` to `server::generation::publish_reload` instead would touch six
36//! signatures, including the `serve_on*` seams every test enters through.
37//! When it is unset — a test binary, or a consumer that installed its own
38//! subscriber — [`publish_logging`] is a **no-op that says so**, since
39//! logging is then not ours to swap.
40//!
41//! The cost, stated rather than buried: a `reload::Layer` puts an `RwLock` read
42//! on every event.
43
44use std::sync::OnceLock;
45
46use tracing_subscriber::fmt::format::FmtSpan;
47use tracing_subscriber::fmt::writer::BoxMakeWriter;
48use tracing_subscriber::layer::SubscriberExt;
49use tracing_subscriber::util::SubscriberInitExt;
50use tracing_subscriber::{EnvFilter, Layer, Registry, reload};
51
52/// The layer stack as one value, so a reload can replace all of it at once.
53///
54/// Boxed against `Registry` specifically: that is the base subscriber both
55/// [`init_logging`] and every swap build on.
56type Installed = Box<dyn Layer<Registry> + Send + Sync>;
57
58/// The handle to the installed stack, or unset when this process installed no
59/// subscriber of its own. See the reloading notes in the module doc.
60static RELOAD: OnceLock<reload::Handle<Installed, Registry>> = OnceLock::new();
61
62/// The `--log-level` directive this process was started with, or unset when the
63/// flag was not given.
64///
65/// Beside [`RELOAD`] and for its reason, stated in the module doc: a reload
66/// rebuilds the stack from the *file*, so without somewhere process-wide to
67/// read the flag back from, `SIGHUP` would silently drop it — and threading it
68/// down instead would touch the same six signatures, `serve_on*` included.
69/// Written once, after the value has been validated by building a filter from
70/// it, so a refused startup leaves nothing behind.
71static FILTER_OVERRIDE: OnceLock<String> = OnceLock::new();
72
73/// The `--log-level` directive to apply, or `None` when the flag was not given.
74///
75/// The one accessor for [`FILTER_OVERRIDE`], so a reload re-reads what startup
76/// was told rather than each caller reaching for the cell.
77pub(crate) fn flag_override() -> Option<&'static str> {
78 FILTER_OVERRIDE.get().map(String::as_str)
79}
80
81/// Which of the three layers supplied the filter in force.
82///
83/// The provenance travels with the filter because it decides whether an
84/// operator is owed a warning: with either of the two outranking layers in
85/// play, editing `logging.filter` and reloading changes nothing at all, and a
86/// silent no-op is the one outcome worth a line in the log. It is also the
87/// `source` field on that warning, so the operator is told *which* to unset.
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub(crate) enum FilterSource {
90 /// `--log-level`, typed on this command line.
91 Flag,
92 /// `RUST_LOG`, from the environment.
93 Env,
94 /// `logging.filter`, from the configuration file.
95 Config,
96}
97
98impl FilterSource {
99 /// The `source` field's value on `server_logging_filter_overridden`.
100 pub(crate) fn as_str(self) -> &'static str {
101 match self {
102 Self::Flag => "flag",
103 Self::Env => "env",
104 Self::Config => "config",
105 }
106 }
107
108 /// Whether `logging.filter` was overruled, i.e. whether editing it and
109 /// reloading would change nothing.
110 pub(crate) fn outranks_config(self) -> bool {
111 !matches!(self, Self::Config)
112 }
113}
114
115/// The tracing filter, and where it came from.
116#[derive(Debug)]
117struct ResolvedFilter {
118 filter: EnvFilter,
119 source: FilterSource,
120}
121
122/// Builds the tracing filter: `flag` if given, else `RUST_LOG` if set and
123/// valid, else `logging.filter`.
124///
125/// Returns an error rather than unwrapping. `logging.filter` is
126/// operator-supplied and environment-overridable, so a typo in it used to
127/// panic the process with a backtrace — four lines after a configuration error
128/// was handled cleanly with a message and an exit code. Exiting rather than
129/// silently falling back to a default is the deliberate half: a certificate
130/// authority quietly running at a different log level than its operator asked
131/// for is worse than one that refuses to start and says why.
132///
133/// `flag` outranks `RUST_LOG` for `cli::style`'s reason: it was typed on
134/// this command line, where the environment is ambient. It is a `LogLevel`
135/// rendering rather than operator text, so it cannot fail to parse — but it
136/// goes through the same `try_new` as the other two rather than being trusted,
137/// since a value that cannot fail is one nobody notices becoming able to.
138///
139/// The precedence is the same on a reload as at startup, and deliberately: the
140/// two disagreeing about what the server is running would be worse than the
141/// override itself.
142fn build_env_filter(
143 logging: &acme_proxy_core::config::LoggingConfig,
144 flag: Option<&str>,
145) -> Result<ResolvedFilter, String> {
146 if let Some(directive) = flag {
147 return EnvFilter::try_new(directive)
148 .map(|filter| ResolvedFilter {
149 filter,
150 source: FilterSource::Flag,
151 })
152 .map_err(|error| {
153 format!("--log-level `{directive}` is not a valid tracing filter: {error}")
154 });
155 }
156 if let Ok(filter) = EnvFilter::try_from_default_env() {
157 return Ok(ResolvedFilter {
158 filter,
159 source: FilterSource::Env,
160 });
161 }
162 EnvFilter::try_new(&logging.filter)
163 .map(|filter| ResolvedFilter {
164 filter,
165 source: FilterSource::Config,
166 })
167 .map_err(|error| {
168 format!(
169 "configuration error: logging.filter `{}` is not a valid tracing filter: {error}",
170 logging.filter
171 )
172 })
173}
174
175/// Resolves `logging.target` to the writer records are sent to.
176///
177/// Boxed so both values leave this function as one type — the two `fmt`
178/// builder chains below are already split by `json_format`, and splitting them
179/// again by writer would be four arms saying the same thing.
180fn parse_target(target: &str) -> Result<BoxMakeWriter, String> {
181 match target {
182 "stdout" => Ok(BoxMakeWriter::new(std::io::stdout)),
183 "stderr" => Ok(BoxMakeWriter::new(std::io::stderr)),
184 other => Err(format!(
185 "configuration error: logging.target `{other}` is not a known target (stdout, stderr)"
186 )),
187 }
188}
189
190/// Resolves `logging.span_events` to the span lifecycle records emitted.
191///
192/// `close` is the one worth reaching for: it emits a record as each span ends,
193/// carrying the time spent busy and idle inside it — per-request timing without
194/// a metrics endpoint. `full` adds `new`/`enter`/`exit` and is a debugging tool,
195/// not something to run a server on.
196fn parse_span_events(span_events: &str) -> Result<FmtSpan, String> {
197 match span_events {
198 "none" => Ok(FmtSpan::NONE),
199 "close" => Ok(FmtSpan::CLOSE),
200 "full" => Ok(FmtSpan::FULL),
201 other => Err(format!(
202 "configuration error: logging.span_events `{other}` is not a known value (none, close, full)"
203 )),
204 }
205}
206
207/// Whether to colour the human-readable format: `logging.ansi`, with `NO_COLOR`
208/// able to veto it.
209///
210/// `tracing-subscriber` honours `NO_COLOR` in its own default, and calling
211/// `with_ansi` at all replaces that default outright — so configuring this key
212/// naively would have silently broken the convention for every operator who
213/// relies on it. Either switch turns colour off; neither can turn it on against
214/// the other.
215///
216/// Per the convention, `NO_COLOR` counts only when set to a non-empty value —
217/// which is [`no_color_set`](acme_proxy_core::palette::no_color_set)'s judgement, shared with the admin
218/// CLI's own `--color` so the two answers cannot drift. Note the *precedence*
219/// deliberately does not match: a `--color always` outranks `NO_COLOR` where
220/// this key cannot, because a flag is typed and a configuration file is
221/// ambient. `cli::style`'s module doc has the argument.
222fn ansi_enabled(configured: bool, no_color: Option<&str>) -> bool {
223 configured && !acme_proxy_core::palette::no_color_set(no_color)
224}
225
226/// A layer stack built from `[logging]` but not yet installed.
227///
228/// The build/publish split is [`crate::Assembly::build_parts`] and
229/// `publish_notifiers`', for the same reason: a reload must be able to fail
230/// *after* building this and still leave the running configuration untouched.
231pub(crate) struct PreparedLogging {
232 layer: Installed,
233 /// Which of the three layers the filter came from, and so whether
234 /// `logging.filter` had any say.
235 pub(crate) filter_source: FilterSource,
236}
237
238/// Resolves `[logging]` into a layer stack, validating every key.
239///
240/// The one place a stack is built, so startup and a reload cannot drift — the
241/// reasoning behind [`crate::generation::build_generation`], applied
242/// to one layer.
243pub(crate) fn prepare_logging(
244 logging: &acme_proxy_core::config::LoggingConfig,
245 flag: Option<&str>,
246) -> Result<PreparedLogging, String> {
247 let ResolvedFilter { filter, source } = build_env_filter(logging, flag)?;
248 let writer = parse_target(&logging.target)?;
249 let span_events = parse_span_events(&logging.span_events)?;
250 let ansi = ansi_enabled(logging.ansi, std::env::var("NO_COLOR").ok().as_deref());
251
252 // The two arms are separate because `.json()` changes the layer's type, not
253 // because they differ in what they configure.
254 //
255 // **`format.and_then(filter)`, never the other way round.** `and_then`
256 // makes its argument the *outer* layer, and `Layered::max_level_hint`
257 // directly over a `Registry` returns the outer hint alone — so composing
258 // them the readable way round hands the format layer's `None` to
259 // `LevelFilter::current()`, which then sits at `TRACE` for the life of the
260 // process. Every record would still be filtered correctly by `enabled`, so
261 // nothing would look wrong; the cost is that `tracing`'s static
262 // short-circuit stops working and every disabled callsite in the tree pays
263 // a subscriber call.
264 let layer: Installed = if logging.json_format {
265 tracing_subscriber::fmt::layer()
266 .json()
267 .flatten_event(logging.flatten_event)
268 .with_span_events(span_events)
269 .with_writer(writer)
270 .and_then(filter)
271 .boxed()
272 } else {
273 tracing_subscriber::fmt::layer()
274 .with_ansi(ansi)
275 .with_span_events(span_events)
276 .with_writer(writer)
277 .and_then(filter)
278 .boxed()
279 };
280
281 Ok(PreparedLogging {
282 layer,
283 filter_source: source,
284 })
285}
286
287/// Makes `prepared` the stack every later record goes through, reporting
288/// whether it took.
289///
290/// `false` means this process installed no subscriber of its own, so there is
291/// no handle and nothing was swapped — see the module doc. Synchronous, which
292/// is what lets it sit inside `server::generation::publish_reload`'s publishing
293/// run beside the `watch` sends.
294pub(crate) fn publish_logging(prepared: PreparedLogging) -> bool {
295 RELOAD
296 .get()
297 .is_some_and(|handle| handle.reload(prepared.layer).is_ok())
298}
299
300/// Installs the process-wide tracing subscriber from `[logging]`.
301///
302/// Every knob is validated before anything is installed, and an unknown value
303/// is an error the caller prints and exits on rather than a silent fallback —
304/// the same reasoning as [`build_env_filter`]: a certificate authority running
305/// at a log level or to a destination its operator did not ask for is worse
306/// than one that refuses to start and says why.
307pub fn init_logging(
308 logging: &acme_proxy_core::config::LoggingConfig,
309 directive: Option<String>,
310) -> Result<(), String> {
311 let prepared = prepare_logging(logging, directive.as_deref())?;
312 let (layer, handle) = reload::Layer::new(prepared.layer);
313 tracing_subscriber::registry().with(layer).init();
314 // A second install would already have panicked in `init()` above, so the
315 // only way this loses the race is a caller that never got that far.
316 let _ = RELOAD.set(handle);
317 // Stored only now: a directive that would not build must leave the cell
318 // unset, or a refused startup would hand a reload a filter nothing ever
319 // installed.
320 if let Some(directive) = directive {
321 let _ = FILTER_OVERRIDE.set(directive);
322 }
323 Ok(())
324}
325
326/// The stack a one-shot admin command logs through, when it logs at all.
327///
328/// **`stderr`, whatever `logging.target` says**, and `logging.filter` is not
329/// consulted either: that section describes the *server's* log stream, while
330/// this is a diagnostic an operator asked one command for. stdout is the answer
331/// — the rows, or the `--json` document — and putting a record in it is what
332/// this whole path exists to stop.
333///
334/// Human-readable rather than `logging.json_format`'s shape for the same
335/// reason: the audience is the terminal the command was typed into. `NO_COLOR`
336/// still vetoes the colour, through the shared [`ansi_enabled`].
337fn command_logging_config() -> acme_proxy_core::config::LoggingConfig {
338 acme_proxy_core::config::LoggingConfig {
339 target: "stderr".to_string(),
340 // The compiled default, deliberately, and not the operator's own
341 // `logging.filter`. It is reached only when `--log-level` was not
342 // given, i.e. when a non-empty `RUST_LOG` is what asked — and that
343 // outranks it, so this is a fallback nothing normally reads.
344 ..acme_proxy_core::config::LoggingConfig::default()
345 }
346}
347
348/// Installs the diagnostic subscriber a one-shot admin command asked for.
349///
350/// `directive` is `None` when a non-empty `RUST_LOG` is what asked, in which case
351/// it supplies the filter through [`build_env_filter`]'s second layer — see
352/// `cli::plan_logging`, which is what decides this function is called at all.
353pub fn init_command_logging(directive: Option<String>) -> Result<(), String> {
354 init_logging(&command_logging_config(), directive)
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use tracing::level_filters::LevelFilter;
361
362 /// A malformed `logging.filter` is an error a caller can print, not a
363 /// panic. It is operator-supplied and environment-overridable, so a typo
364 /// used to take the process down with a backtrace — four lines after a
365 /// configuration error was handled cleanly.
366 #[test]
367 fn a_malformed_logging_filter_is_reported_rather_than_panicking() {
368 // `RUST_LOG` outranks `logging.filter`, so a developer with one set in
369 // their shell would otherwise see this pass on an answer that never
370 // looked at the malformed value.
371 let _guard = acme_proxy_core::config::ENV_LOCK
372 .lock()
373 .unwrap_or_else(std::sync::PoisonError::into_inner);
374 // SAFETY: the lock above makes this the only thread touching the
375 // environment.
376 unsafe { std::env::remove_var("RUST_LOG") };
377
378 let logging = acme_proxy_core::config::LoggingConfig {
379 filter: "this is not=a=valid=filter".to_string(),
380 ..Default::default()
381 };
382 let error = build_env_filter(&logging, None).unwrap_err();
383 assert!(error.contains("logging.filter"), "{error}");
384 assert!(error.contains("this is not=a=valid=filter"), "{error}");
385 }
386
387 #[test]
388 fn a_valid_logging_filter_builds() {
389 let _guard = acme_proxy_core::config::ENV_LOCK
390 .lock()
391 .unwrap_or_else(|e| e.into_inner());
392 unsafe { std::env::remove_var("RUST_LOG") };
393
394 let logging = acme_proxy_core::config::LoggingConfig {
395 filter: "acme_proxy=debug".to_string(),
396 ..Default::default()
397 };
398 let resolved = build_env_filter(&logging, None).expect("a valid filter builds");
399 assert_eq!(
400 resolved.source,
401 FilterSource::Config,
402 "with RUST_LOG unset and no flag the filter comes from `logging.filter`",
403 );
404 }
405
406 /// The provenance the reload path warns off: `RUST_LOG` wins, so an edited
407 /// `logging.filter` would change nothing.
408 #[test]
409 fn rust_log_wins_and_says_so() {
410 let _guard = acme_proxy_core::config::ENV_LOCK
411 .lock()
412 .unwrap_or_else(|e| e.into_inner());
413 unsafe { std::env::set_var("RUST_LOG", "acme_proxy=warn") };
414
415 let logging = acme_proxy_core::config::LoggingConfig {
416 filter: "acme_proxy=trace".to_string(),
417 ..Default::default()
418 };
419 let resolved = build_env_filter(&logging, None).expect("RUST_LOG parses");
420 assert_eq!(resolved.source, FilterSource::Env);
421
422 unsafe { std::env::remove_var("RUST_LOG") };
423 }
424
425 /// `NO_COLOR` must keep working: `tracing-subscriber` honours it in the
426 /// default that `with_ansi` replaces, so configuring the key at all is what
427 /// put the convention at risk.
428 #[test]
429 fn no_color_vetoes_ansi_and_an_empty_value_does_not() {
430 assert!(ansi_enabled(true, None));
431 assert!(!ansi_enabled(true, Some("1")));
432 assert!(!ansi_enabled(true, Some("anything")));
433 // The convention counts only a non-empty value.
434 assert!(ansi_enabled(true, Some("")));
435 // Configured off stays off however NO_COLOR is set.
436 assert!(!ansi_enabled(false, None));
437 assert!(!ansi_enabled(false, Some("1")));
438 }
439
440 #[test]
441 fn both_logging_targets_resolve() {
442 assert!(parse_target("stdout").is_ok());
443 assert!(parse_target("stderr").is_ok());
444 }
445
446 /// A typo'd target must stop the process, not quietly pick one: an operator
447 /// who asked for `stderr` and silently got `stdout` would look for the log
448 /// in the wrong stream.
449 #[test]
450 fn an_unknown_logging_target_is_reported() {
451 let error = parse_target("syslog").unwrap_err();
452 assert!(error.contains("logging.target"), "{error}");
453 assert!(error.contains("syslog"), "{error}");
454 assert!(error.contains("stdout"), "{error}");
455 }
456
457 #[test]
458 fn every_span_events_value_resolves() {
459 assert_eq!(parse_span_events("none").unwrap(), FmtSpan::NONE);
460 assert_eq!(parse_span_events("close").unwrap(), FmtSpan::CLOSE);
461 assert_eq!(parse_span_events("full").unwrap(), FmtSpan::FULL);
462 }
463
464 #[test]
465 fn an_unknown_span_events_value_is_reported() {
466 let error = parse_span_events("enter").unwrap_err();
467 assert!(error.contains("logging.span_events"), "{error}");
468 assert!(error.contains("enter"), "{error}");
469 assert!(error.contains("close"), "{error}");
470 }
471
472 /// `prepare_logging` validates everything before building anything, so each
473 /// bad key is reported by name — which is what makes a reload carrying one
474 /// a refusal with the message startup would have printed, rather than a
475 /// half-swapped stack. `init_logging` funnels through it, so the failure
476 /// path below covers both.
477 #[test]
478 fn prepare_logging_reports_each_bad_key_by_name() {
479 for (logging, expected) in bad_key_cases() {
480 let _guard = acme_proxy_core::config::ENV_LOCK
481 .lock()
482 .unwrap_or_else(|e| e.into_inner());
483 unsafe { std::env::remove_var("RUST_LOG") };
484
485 // Not `expect_err`: the `Ok` side holds a boxed `Layer`, which has
486 // no `Debug` to print.
487 let Err(error) = prepare_logging(&logging, None) else {
488 panic!("`{expected}` must be refused, not built");
489 };
490 assert!(error.contains(expected), "{error}");
491 }
492 }
493
494 /// Publishing with no subscriber installed is a documented no-op rather
495 /// than a panic or a lie: this process never called `init_logging`, so
496 /// logging is not ours to swap. The `false` is what
497 /// `ReloadReport::logging_reloaded` carries, so an operator is told.
498 #[test]
499 fn publishing_without_an_installed_subscriber_is_a_no_op() {
500 let _guard = acme_proxy_core::config::ENV_LOCK
501 .lock()
502 .unwrap_or_else(|e| e.into_inner());
503 unsafe { std::env::remove_var("RUST_LOG") };
504
505 let prepared = prepare_logging(&acme_proxy_core::config::LoggingConfig::default(), None)
506 .expect("the defaults build");
507 assert!(!publish_logging(prepared));
508 }
509
510 /// The swap really changes what is enabled, which is the whole feature.
511 ///
512 /// `LevelFilter::current()` is the static maximum `tracing` consults before
513 /// it reaches any subscriber, so asserting it moved is what proves
514 /// `Handle::reload` rebuilt the interest cache rather than merely storing a
515 /// new layer nothing asks. Its own process, like the two installers below.
516 #[test]
517 fn a_reloaded_filter_changes_what_is_enabled() {
518 let _guard = acme_proxy_core::config::ENV_LOCK
519 .lock()
520 .unwrap_or_else(|e| e.into_inner());
521 unsafe { std::env::remove_var("RUST_LOG") };
522
523 let at_info = acme_proxy_core::config::LoggingConfig {
524 filter: "acme_proxy=info".to_string(),
525 target: "stderr".to_string(),
526 ..Default::default()
527 };
528 init_logging(&at_info, None).expect("the subscriber installs");
529 assert_eq!(LevelFilter::current(), LevelFilter::INFO);
530 assert!(!tracing::enabled!(target: "acme_proxy", tracing::Level::DEBUG));
531
532 let at_debug = acme_proxy_core::config::LoggingConfig {
533 filter: "acme_proxy=debug".to_string(),
534 target: "stderr".to_string(),
535 ..Default::default()
536 };
537 let prepared = prepare_logging(&at_debug, None).expect("the debug filter builds");
538 assert!(publish_logging(prepared), "the handle is installed");
539
540 assert_eq!(LevelFilter::current(), LevelFilter::DEBUG);
541 assert!(tracing::enabled!(target: "acme_proxy", tracing::Level::DEBUG));
542 }
543
544 /// The other five keys change the stack's *shape*, which is why the whole
545 /// layer is boxed behind one handle rather than only the filter being
546 /// reloadable. Human-readable to JSON is the biggest such change there is.
547 #[test]
548 fn a_reloaded_format_swaps_the_whole_stack() {
549 let _guard = acme_proxy_core::config::ENV_LOCK
550 .lock()
551 .unwrap_or_else(|e| e.into_inner());
552 unsafe { std::env::remove_var("RUST_LOG") };
553
554 init_logging(
555 &acme_proxy_core::config::LoggingConfig {
556 target: "stderr".to_string(),
557 ansi: false,
558 ..Default::default()
559 },
560 None,
561 )
562 .expect("the subscriber installs");
563
564 let as_json = acme_proxy_core::config::LoggingConfig {
565 json_format: true,
566 flatten_event: true,
567 target: "stderr".to_string(),
568 span_events: "close".to_string(),
569 ..Default::default()
570 };
571 let prepared = prepare_logging(&as_json, None).expect("the JSON stack builds");
572 assert!(publish_logging(prepared));
573
574 // The filter has to survive the shape change: `and_then` puts it on the
575 // outside precisely so `Layered::max_level_hint` keeps reading it, and
576 // building the JSON arm the readable way round would silently drop it
577 // to `TRACE` here.
578 assert_eq!(
579 LevelFilter::current(),
580 LevelFilter::INFO,
581 "swapping the format must not lose the filter's level hint",
582 );
583 }
584
585 /// The three keys whose value can be wrong, and the name each must be
586 /// refused by. Shared so `init_logging` and `prepare_logging` cannot drift
587 /// on which of them they check.
588 fn bad_key_cases() -> Vec<(acme_proxy_core::config::LoggingConfig, &'static str)> {
589 vec![
590 (
591 acme_proxy_core::config::LoggingConfig {
592 filter: "not=a=filter".to_string(),
593 ..Default::default()
594 },
595 "logging.filter",
596 ),
597 (
598 acme_proxy_core::config::LoggingConfig {
599 target: "nowhere".to_string(),
600 ..Default::default()
601 },
602 "logging.target",
603 ),
604 (
605 acme_proxy_core::config::LoggingConfig {
606 span_events: "sometimes".to_string(),
607 ..Default::default()
608 },
609 "logging.span_events",
610 ),
611 ]
612 }
613
614 /// `init_logging` validates everything before installing anything, so each
615 /// bad key is reported by name. Only the failure path is driven here:
616 /// installing a subscriber is process-wide and would leak into every other
617 /// test in this binary.
618 #[test]
619 fn init_logging_reports_each_bad_key_by_name() {
620 for (logging, expected) in bad_key_cases() {
621 // `RUST_LOG` wins over `logging.filter`, so the filter case is only
622 // reachable with it unset — which the crate-wide lock guarantees.
623 let _guard = acme_proxy_core::config::ENV_LOCK
624 .lock()
625 .unwrap_or_else(|e| e.into_inner());
626 unsafe { std::env::remove_var("RUST_LOG") };
627
628 let error = init_logging(&logging, None).unwrap_err();
629 assert!(error.contains(expected), "{error}");
630 }
631 }
632
633 /// The two arms that actually install a subscriber, one per test.
634 ///
635 /// `init()` panics on a second call, so these would be untestable under
636 /// plain `cargo test` — one process, every test a thread. nextest runs each
637 /// test as its own process, which is what makes installing a *global*
638 /// subscriber a thing a test can do at all. (The suite already requires
639 /// nextest for an unrelated reason; see the Testing notes.)
640 #[test]
641 fn the_human_readable_subscriber_installs() {
642 let logging = acme_proxy_core::config::LoggingConfig {
643 target: "stderr".to_string(),
644 ansi: false,
645 span_events: "close".to_string(),
646 ..Default::default()
647 };
648 assert!(init_logging(&logging, None).is_ok());
649 }
650
651 #[test]
652 fn the_json_subscriber_installs() {
653 let logging = acme_proxy_core::config::LoggingConfig {
654 json_format: true,
655 flatten_event: true,
656 span_events: "full".to_string(),
657 ..Default::default()
658 };
659 assert!(init_logging(&logging, None).is_ok());
660 }
661
662 /// A flag was typed on this command line where both other layers are
663 /// ambient — `cli::style`'s argument for `--color always`
664 /// outranking `NO_COLOR`, applied to the filter.
665 #[test]
666 fn the_flag_outranks_rust_log_and_the_file() {
667 let _guard = acme_proxy_core::config::ENV_LOCK
668 .lock()
669 .unwrap_or_else(|e| e.into_inner());
670 unsafe { std::env::set_var("RUST_LOG", "acme_proxy=warn") };
671
672 let logging = acme_proxy_core::config::LoggingConfig {
673 filter: "acme_proxy=error".to_string(),
674 ..Default::default()
675 };
676 let resolved = build_env_filter(&logging, Some("acme_proxy=trace"))
677 .expect("the flag's directive builds");
678 assert_eq!(resolved.source, FilterSource::Flag);
679 assert_eq!(
680 resolved.filter.to_string(),
681 "acme_proxy=trace",
682 "neither RUST_LOG nor logging.filter may have a say once the flag is given",
683 );
684
685 unsafe { std::env::remove_var("RUST_LOG") };
686 }
687
688 /// stdout is the answer an admin command was run for — the rows, or the
689 /// `--json` document — so a record asked for with `--log-level` goes to
690 /// stderr whatever `logging.target` says. That key describes the server's
691 /// stream, and this path deliberately does not consult it.
692 #[test]
693 fn a_command_run_logs_to_stderr_whatever_logging_target_says() {
694 assert_eq!(
695 acme_proxy_core::config::LoggingConfig::default().target,
696 "stdout",
697 "the default this must not inherit",
698 );
699 let built = command_logging_config();
700 assert_eq!(built.target, "stderr");
701 assert!(!built.json_format, "the audience is a terminal");
702 }
703
704 /// The three `FilterSource`s, and the question the reload warning asks of
705 /// them. `config` is the only one that does *not* make an edited
706 /// `logging.filter` a no-op.
707 #[test]
708 fn only_the_two_outranking_sources_silence_an_edit() {
709 assert!(FilterSource::Flag.outranks_config());
710 assert!(FilterSource::Env.outranks_config());
711 assert!(!FilterSource::Config.outranks_config());
712 assert_eq!(FilterSource::Flag.as_str(), "flag");
713 assert_eq!(FilterSource::Env.as_str(), "env");
714 assert_eq!(FilterSource::Config.as_str(), "config");
715 }
716
717 /// The flag's own directives cannot fail to parse — they are renderings of
718 /// a closed enum — but the arm is built to refuse rather than to trust,
719 /// since a value that cannot be wrong is one nobody notices becoming able
720 /// to. Driven with a hand-written directive, which is the only way in.
721 #[test]
722 fn an_unparseable_flag_directive_is_refused_by_name() {
723 let error = build_env_filter(
724 &acme_proxy_core::config::LoggingConfig::default(),
725 Some("not=a=filter"),
726 )
727 .unwrap_err();
728 assert!(error.contains("--log-level"), "{error}");
729 assert!(error.contains("not=a=filter"), "{error}");
730 }
731
732 /// The whole admin-command path, installed: the flag's level in force and
733 /// the records on stderr. Its own process, like the three installers above.
734 #[test]
735 fn a_command_subscriber_installs_at_the_flags_level() {
736 let _guard = acme_proxy_core::config::ENV_LOCK
737 .lock()
738 .unwrap_or_else(|e| e.into_inner());
739 unsafe { std::env::remove_var("RUST_LOG") };
740
741 init_command_logging(Some("acme_proxy=warn".to_string())).expect("the subscriber installs");
742 assert_eq!(LevelFilter::current(), LevelFilter::WARN);
743 assert!(!tracing::enabled!(target: "acme_proxy", tracing::Level::INFO));
744 assert_eq!(flag_override(), Some("acme_proxy=warn"));
745 }
746
747 /// A `--log-level` typed at startup has to survive a `SIGHUP`: the stack is
748 /// rebuilt from the *file*, so without the process-wide cell the reload
749 /// would quietly demote the server to `logging.filter`. Its own process,
750 /// like the three installers above — it installs a global subscriber and
751 /// writes a `OnceLock`.
752 #[test]
753 fn the_flag_survives_a_reload() {
754 let _guard = acme_proxy_core::config::ENV_LOCK
755 .lock()
756 .unwrap_or_else(|e| e.into_inner());
757 unsafe { std::env::remove_var("RUST_LOG") };
758
759 assert!(flag_override().is_none(), "nothing is set before startup");
760
761 let at_error = acme_proxy_core::config::LoggingConfig {
762 filter: "acme_proxy=error".to_string(),
763 target: "stderr".to_string(),
764 ..Default::default()
765 };
766 init_logging(&at_error, Some("acme_proxy=debug".to_string()))
767 .expect("the subscriber installs");
768 assert_eq!(LevelFilter::current(), LevelFilter::DEBUG);
769 assert_eq!(flag_override(), Some("acme_proxy=debug"));
770
771 // The reload's own call, verbatim: a new file with a different filter,
772 // rebuilt through the flag the cell remembers.
773 let edited = acme_proxy_core::config::LoggingConfig {
774 filter: "acme_proxy=error".to_string(),
775 target: "stderr".to_string(),
776 span_events: "close".to_string(),
777 ..Default::default()
778 };
779 let prepared = prepare_logging(&edited, flag_override()).expect("the stack rebuilds");
780 assert_eq!(prepared.filter_source, FilterSource::Flag);
781 assert!(publish_logging(prepared));
782 assert_eq!(
783 LevelFilter::current(),
784 LevelFilter::DEBUG,
785 "the reload must not demote the server to the file's filter",
786 );
787 }
788}