Skip to main content

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}