Skip to main content

acme_proxy/cli/
logging.rs

1//! Which invocation gets a subscriber, and at what level — the CLI's half of
2//! `[logging]`. Installing and reloading the stack is
3//! [`acme_proxy_server::logging`]'s.
4//!
5//! # Who gets a subscriber
6//!
7//! `[logging]` describes the **server's** log stream, and until this module
8//! grew [`plan_logging`] every subcommand got it: with the shipped defaults
9//! (`acme_proxy=info`, to **stdout**) `acme-proxy account list --json | jq`
10//! read a `db_migration_completed` record before the JSON, and `filter
11//! explain` wrote a `warn` into the middle of the explanation it was printing.
12//!
13//! So the decision is now made per invocation, by a pure function a test can
14//! drive rather than in `main.rs`, which the coverage floor excludes:
15//!
16//! - `serve` gets the stack `[logging]` describes, exactly as before.
17//! - **Any other subcommand emits nothing at all** unless the operator asks —
18//!   with `--log-level`, or with a non-empty `RUST_LOG`. There is no subscriber
19//!   in that case, so every `tracing` call in the process is a no-op rather
20//!   than a filtered one.
21//! - When one does ask, the records go to **stderr** whatever `logging.target`
22//!   says, because stdout is the answer the operator's `jq` or `awk` is reading
23//!   and a diagnostic does not belong in it.
24//!
25//! [`LogLevel`] outranks both `RUST_LOG` and `logging.filter`, which is
26//! [`super::style`]'s argument for `--color always` outranking `NO_COLOR`: a
27//! flag was typed on this command line where the other two are ambient. See
28//! [`FilterSource`], which travels with the filter so a reload can say which of
29//! the three won.
30//!
31
32use clap::ValueEnum;
33
34/// `--log-level`: how much this invocation logs.
35///
36/// A crate-local enum rather than `tracing::Level` for two reasons: `off` is
37/// not a level, and `clap`'s [`ValueEnum`] cannot be implemented for a foreign
38/// type anyway. Being a `value_enum` is also what puts the six values into the
39/// generated shell completions, which is what `--color` already buys.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
41pub enum LogLevel {
42    Off,
43    Error,
44    Warn,
45    Info,
46    Debug,
47    Trace,
48}
49
50impl LogLevel {
51    /// The `EnvFilter` directive this level asks for.
52    ///
53    /// **Scoped to this crate's own target**, so `--log-level debug` does not
54    /// also unleash `sqlx`, `hyper` and `rustls` on someone who wanted to see
55    /// why one command behaved oddly. `RUST_LOG` stays the way to write a
56    /// directive that reaches further — it is the same string this would have
57    /// to become, and a flag that took one would be a second spelling of it.
58    #[must_use]
59    pub fn directive(self) -> String {
60        match self {
61            // Not `acme_proxy=off`: a per-target directive at `off` still
62            // leaves every *other* target at the default level, so the one
63            // value asking for silence would be the one that did not deliver
64            // it.
65            Self::Off => "off".to_string(),
66            Self::Error => "acme_proxy=error".to_string(),
67            Self::Warn => "acme_proxy=warn".to_string(),
68            Self::Info => "acme_proxy=info".to_string(),
69            Self::Debug => "acme_proxy=debug".to_string(),
70            Self::Trace => "acme_proxy=trace".to_string(),
71        }
72    }
73}
74
75/// Which subscriber, if any, an invocation installs.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum LoggingPlan {
78    /// Install nothing. Every `tracing` call in the process is then a no-op,
79    /// which is stronger — and cheaper — than a subscriber filtering them all
80    /// out.
81    Silent,
82    /// Install the stack `[logging]` describes: the server's own log stream.
83    Server,
84    /// Install a diagnostic stack on stderr for a one-shot admin command.
85    Command,
86}
87
88/// Decides what this invocation logs, from the subcommand and the two ways an
89/// operator can ask.
90///
91/// Pure, and here rather than in `src/main.rs` because that file is excluded
92/// from the coverage floor: every rule below is a row of a table test.
93///
94/// `rust_log` counts only when **non-empty**, which is
95/// [`no_color_set`](acme_proxy_core::palette::no_color_set)'s judgement applied to the other ambient
96/// environment variable this CLI reads. A `RUST_LOG=` left behind by a
97/// `${RUST_LOG:-}`-style shell default is not somebody asking for logs, and
98/// treating it as one would put records back in the pipe this exists to keep
99/// clean.
100pub fn plan_logging(
101    command: Option<&super::Command>,
102    level: Option<LogLevel>,
103    rust_log: Option<&str>,
104) -> LoggingPlan {
105    match command {
106        // A daemon logs; the flag only sharpens what it says. `None` is the
107        // default subcommand, i.e. a bare `acme-proxy`.
108        None | Some(super::Command::Serve { .. }) => LoggingPlan::Server,
109        // `main.rs` answers both before it loads a configuration, so neither
110        // reaches here — but answering them keeps this total over `Command`,
111        // the rule `dispatch` follows for the same pair.
112        Some(super::Command::Completions { .. } | super::Command::Man) => LoggingPlan::Silent,
113        Some(_) => {
114            if level.is_some() || rust_log.is_some_and(|value| !value.is_empty()) {
115                LoggingPlan::Command
116            } else {
117                LoggingPlan::Silent
118            }
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use tracing_subscriber::EnvFilter;
127
128    /// One row of the `plan_logging` table: a command line, the flag, the
129    /// environment, and what the three of them must decide.
130    type PlanCase<'a> = (
131        &'a [&'a str],
132        Option<LogLevel>,
133        Option<&'a str>,
134        LoggingPlan,
135    );
136
137    /// Parsed rather than hand-built: naming every subcommand enum here would
138    /// be a second copy of the clap tree, and what has to be right is what a
139    /// real command line resolves to.
140    fn command_of(argv: &[&str]) -> Option<super::super::Command> {
141        use clap::Parser;
142        super::super::Cli::try_parse_from(argv)
143            .expect("the fixture command line parses")
144            .command
145    }
146
147    /// The whole point of the flag, as a table: **an admin command is silent
148    /// unless somebody asked**, `serve` never is, and `RUST_LOG` counts only
149    /// when it holds something.
150    #[test]
151    fn plan_logging_decides_who_gets_a_subscriber() {
152        let cases: Vec<PlanCase> = vec![
153            // A daemon logs, however it was reached and whatever is unset.
154            (&["acme-proxy"], None, None, LoggingPlan::Server),
155            (&["acme-proxy", "serve"], None, None, LoggingPlan::Server),
156            (
157                &["acme-proxy", "serve"],
158                Some(LogLevel::Debug),
159                None,
160                LoggingPlan::Server,
161            ),
162            // The reported bug: these used to write `db_migration_completed`
163            // into the operator's `jq` pipe.
164            (
165                &["acme-proxy", "account", "list"],
166                None,
167                None,
168                LoggingPlan::Silent,
169            ),
170            (
171                &["acme-proxy", "filter", "show"],
172                None,
173                None,
174                LoggingPlan::Silent,
175            ),
176            (
177                &["acme-proxy", "audit", "list"],
178                None,
179                None,
180                LoggingPlan::Silent,
181            ),
182            (
183                &["acme-proxy", "admin", "user", "list"],
184                None,
185                None,
186                LoggingPlan::Silent,
187            ),
188            // Both ways of asking, and only those two.
189            (
190                &["acme-proxy", "account", "list"],
191                Some(LogLevel::Debug),
192                None,
193                LoggingPlan::Command,
194            ),
195            (
196                &["acme-proxy", "account", "list"],
197                Some(LogLevel::Off),
198                None,
199                LoggingPlan::Command,
200            ),
201            (
202                &["acme-proxy", "account", "list"],
203                None,
204                Some("acme_proxy=debug"),
205                LoggingPlan::Command,
206            ),
207            // A `${RUST_LOG:-}` shell default is present, not a request — the
208            // judgement `no_color_set` already makes about the other ambient
209            // variable this CLI reads.
210            (
211                &["acme-proxy", "account", "list"],
212                None,
213                Some(""),
214                LoggingPlan::Silent,
215            ),
216            // Answered by `main.rs` before any of this, but total here anyway.
217            (&["acme-proxy", "man"], None, None, LoggingPlan::Silent),
218            (
219                &["acme-proxy", "completions", "bash"],
220                Some(LogLevel::Trace),
221                Some("debug"),
222                LoggingPlan::Silent,
223            ),
224        ];
225
226        for (argv, level, rust_log, expected) in cases {
227            let command = command_of(argv);
228            let plan = plan_logging(command.as_ref(), level, rust_log);
229            assert_eq!(
230                plan,
231                expected,
232                "`{}` with level {level:?} and RUST_LOG {rust_log:?}",
233                argv.join(" "),
234            );
235        }
236    }
237
238    /// Each level's directive is **scoped to this crate**, so `--log-level
239    /// debug` does not also unleash `sqlx` and `hyper` on somebody debugging
240    /// one command. `off` is the exception and has to be: a per-target
241    /// directive at `off` leaves every other target at the default level, so
242    /// the one value asking for silence would be the one not delivering it.
243    #[test]
244    fn every_level_renders_a_directive_scoped_to_this_crate() {
245        assert_eq!(LogLevel::Off.directive(), "off");
246        for (level, expected) in [
247            (LogLevel::Error, "acme_proxy=error"),
248            (LogLevel::Warn, "acme_proxy=warn"),
249            (LogLevel::Info, "acme_proxy=info"),
250            (LogLevel::Debug, "acme_proxy=debug"),
251            (LogLevel::Trace, "acme_proxy=trace"),
252        ] {
253            assert_eq!(level.directive(), expected);
254            EnvFilter::try_new(level.directive()).expect("every directive parses");
255        }
256        EnvFilter::try_new(LogLevel::Off.directive()).expect("`off` parses");
257    }
258}