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}