cloudpub_common/logging.rs
1use anyhow::{Context as _, Result};
2use rolling_file::{BasicRollingFileAppender, RollingConditionBasic};
3use std::path::Path;
4use tracing::debug;
5pub use tracing_appender::non_blocking::WorkerGuard;
6use tracing_log::LogTracer;
7use tracing_subscriber::fmt;
8use tracing_subscriber::fmt::time::FormatTime;
9use tracing_subscriber::prelude::*;
10
11/// The default trace-filter directives, RUST_LOG aside.
12///
13/// The public listener faces the whole internet, and pingora reports every
14/// failed TLS handshake and every garbage HTTP request a scanner sends at
15/// ERROR - hundreds of thousands of lines a day on prod. Those modules are
16/// silenced outright; the rest of pingora_core is capped at ERROR to drop its
17/// routine idle-connection and response-header warnings - CLOUDPUB-1281.
18pub fn trace_filter(level: &str) -> String {
19 format!(
20 "{},hyper=error,tokio_postgres=info,pingora_core=error,pingora_core::services::listening=off,pingora_core::apps=off,pingora_proxy=off,pingora_pool=error,tungstenite=info",
21 level
22 )
23}
24
25/// The ceiling for the `log` crate, derived from the configured level.
26///
27/// A `log` macro evaluates its arguments whenever `log::max_level()` admits
28/// the record, and pingora logs raw upstream response headers with
29/// `trace!("Raw Response header: {:?}", str::from_utf8(..).unwrap())`. Left
30/// at Trace - which is what a bare `LogTracer::init()` does, deferring all
31/// filtering to the tracing subscriber - that unwrap runs on every upstream
32/// response and panics the proxy worker on the first header carrying
33/// non-UTF-8 bytes (CLOUDPUB-1507). Capping the `log` crate at the level the
34/// operator actually configured keeps those arguments unevaluated, and saves
35/// the formatting work besides.
36///
37/// Parsed the way the tracing `EnvFilter` built from the same string parses
38/// it - case-insensitively, whitespace trimmed - so the ceiling never lands
39/// below the configured level and drops records the operator asked for.
40/// Anything unparseable falls back to Info: the fallback must not be the one
41/// value that reinstates the panic.
42pub fn log_max_level(level: &str) -> log::LevelFilter {
43 match level.trim().to_ascii_lowercase().as_str() {
44 "off" => log::LevelFilter::Off,
45 "error" => log::LevelFilter::Error,
46 "warn" => log::LevelFilter::Warn,
47 "debug" => log::LevelFilter::Debug,
48 "trace" => log::LevelFilter::Trace,
49 _ => log::LevelFilter::Info,
50 }
51}
52
53/// The zone that renders the timestamp at the head of a log line.
54///
55/// The two variants serve two different audiences, and that is deliberate -
56/// not a divergence waiting to be unified:
57///
58/// - The server logs in [`LogZone::Utc`]: server.log is read next to
59/// backend.log and security.log, which Django writes in UTC (USE_TZ), and
60/// a local-zone stamp skews the incident timeline across the files by the
61/// zone offset - three hours on the prod MSK hosts (CLOUDPUB-1474).
62/// - The client logs in [`LogZone::Local`]: its log is read by one person on
63/// their own machine, correlating entries with what they just did by hand.
64/// The stamp format carries no offset of its own, so a UTC stamp would
65/// show that person a shifted time with no sign it is UTC - it would read
66/// as broken clocks, not as UTC. Showing the zone in the format is the
67/// prerequisite for ever moving the client to UTC, and the format is
68/// deliberately outside this ticket's scope.
69pub enum LogZone {
70 Utc,
71 Local,
72}
73
74/// The timestamp put at the head of every log line: `YYYY-MM-DD HH:MM:SS`,
75/// in the zone `LogZone` asks for. The format string carries no offset of
76/// its own, so the zone is invisible in the line and only observable in the
77/// rendered digits - see the test below, which pins the server's choice.
78///
79/// Extracted from `init_log` so that zone is pinnable by a test.
80fn log_timer(zone: LogZone) -> Result<impl FormatTime + Clone> {
81 let format = time::format_description::parse_borrowed::<2>(
82 "[year]-[month padding:zero]-[day padding:zero] [hour]:[minute]:[second]",
83 )
84 .context("Failed to parse time format")?;
85
86 let offset = match zone {
87 LogZone::Utc => time::UtcOffset::UTC,
88 LogZone::Local => time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC),
89 };
90 Ok(fmt::time::OffsetTime::new(offset, format))
91}
92
93pub fn init_log(
94 level: &str,
95 log_file: &Path,
96 stderr: bool,
97 zone: LogZone,
98 max_size: usize,
99 max_files: usize,
100) -> Result<WorkerGuard> {
101 let file_appender = BasicRollingFileAppender::new(
102 log_file,
103 RollingConditionBasic::new().max_size(max_size as u64), // 10 MB
104 max_files,
105 )
106 .context("Failed to create rolling file appender")?;
107
108 let (file_writer, guard) = tracing_appender::non_blocking(file_appender);
109
110 let trace_cfg = format!(
111 "{},{}",
112 trace_filter(level),
113 std::env::var("RUST_LOG").unwrap_or_default()
114 );
115
116 let timer = log_timer(zone).context("Failed to build log timer")?;
117
118 // Create the file layer with common settings
119 let file_layer = fmt::Layer::default()
120 .with_timer(timer.clone())
121 .with_ansi(false)
122 .with_writer(file_writer);
123
124 // Build a registry with the file layer and conditional stderr layer
125 let registry = tracing_subscriber::registry()
126 .with(tracing_subscriber::EnvFilter::new(trace_cfg))
127 .with(file_layer);
128
129 // Add stderr layer only if requested
130 if stderr {
131 let stderr_layer = fmt::Layer::default()
132 .with_timer(timer)
133 .with_ansi(cfg!(unix))
134 .with_writer(std::io::stderr);
135
136 tracing::subscriber::set_global_default(registry.with(stderr_layer))
137 .context("Failed to set global default subscriber")?;
138 } else {
139 tracing::subscriber::set_global_default(registry)
140 .context("Failed to set global default subscriber")?;
141 }
142
143 LogTracer::builder()
144 .with_max_level(log_max_level(level))
145 .init()
146 .context("Failed to initialize log tracer")?;
147 log_panics::init();
148
149 debug!("Tracing initialized ({})", level);
150 Ok(guard)
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use std::io;
157 use std::sync::{Arc, Mutex};
158 use tracing::subscriber::with_default;
159 use tracing_subscriber::fmt::MakeWriter;
160 use tracing_subscriber::layer::{Context, Layer};
161 use tracing_subscriber::registry::LookupSpan;
162
163 #[derive(Clone, Default)]
164 struct Capture(Arc<Mutex<Vec<String>>>);
165
166 impl<S: tracing::Subscriber + for<'a> LookupSpan<'a>> Layer<S> for Capture {
167 fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
168 self.0
169 .lock()
170 .unwrap()
171 .push(event.metadata().target().to_string());
172 }
173 }
174
175 /// Internet scanners hammer the public TLS port and pingora logs every
176 /// failed handshake and every garbage HTTP request at ERROR - hundreds of
177 /// thousands of lines a day on prod, drowning out real errors. The trace
178 /// filter has to drop that noise while keeping the server's own errors.
179 /// STATUS: FIXED - see CLOUDPUB-1281
180 #[test]
181 fn trace_filter_suppresses_scanner_noise() {
182 let capture = Capture::default();
183 let filter = tracing_subscriber::EnvFilter::new(trace_filter("error"));
184 let subscriber = tracing_subscriber::registry()
185 .with(filter)
186 .with(capture.clone());
187
188 with_default(subscriber, || {
189 // Scanner noise pingora reports at ERROR/WARN level
190 tracing::error!(
191 target: "pingora_core::services::listening",
192 "Downstream handshake error: TLS accept() failed"
193 );
194 tracing::error!(
195 target: "pingora_core::apps::http_app",
196 "HTTP server fails to read from downstream: InvalidHTTPHeader"
197 );
198 tracing::warn!(
199 target: "pingora_core::connectors",
200 "Unexpected data read in idle connection"
201 );
202 tracing::warn!(
203 target: "pingora_core::protocols::http::v1::server",
204 "Respond header is already sent, cannot send again"
205 );
206 // The server's own errors must keep flowing
207 tracing::error!(target: "server::control", "real error");
208 });
209
210 let targets = capture.0.lock().unwrap();
211 assert!(
212 !targets.iter().any(|t| t.starts_with("pingora_core")),
213 "pingora scanner noise must be filtered out, got: {:?}",
214 targets
215 );
216 assert!(
217 targets.iter().any(|t| t == "server::control"),
218 "the server's own errors must not be filtered out, got: {:?}",
219 targets
220 );
221 }
222
223 /// In-memory sink for the formatted log line: the fmt layer writes the
224 /// whole `timestamp LEVEL target: message` record here, and the test reads
225 /// the stamp the way a log consumer grepping the file would.
226 #[derive(Clone, Default)]
227 struct LineSink(Arc<Mutex<Vec<u8>>>);
228
229 impl<'a> MakeWriter<'a> for LineSink {
230 type Writer = LineWriter;
231
232 fn make_writer(&'a self) -> LineWriter {
233 LineWriter(self.0.clone())
234 }
235 }
236
237 struct LineWriter(Arc<Mutex<Vec<u8>>>);
238
239 impl io::Write for LineWriter {
240 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
241 self.0.lock().unwrap().extend_from_slice(buf);
242 Ok(buf.len())
243 }
244
245 fn flush(&mut self) -> io::Result<()> {
246 Ok(())
247 }
248 }
249
250 /// Decodes a `YYYY-MM-DD HH:MM:SS` stamp taken off a log line into an
251 /// instant on the UTC timeline. The digits are split and rebuilt as a
252 /// calendar date by hand, independently of the format machinery under
253 /// test: whatever the timer wrote, it is read back as UTC - which is the
254 /// contract, since the format string carries no offset of its own.
255 fn stamp_as_utc(line: &str) -> time::OffsetDateTime {
256 let stamp = &line[..19]; // "YYYY-MM-DD HH:MM:SS" is exactly 19 chars
257 let fields: Vec<&str> = stamp.split(['-', ' ', ':']).collect();
258 assert_eq!(fields.len(), 6, "malformed stamp {stamp:?} in {line:?}");
259 let number = |i: usize| {
260 fields[i]
261 .parse::<u32>()
262 .unwrap_or_else(|e| panic!("field {i} of {stamp:?}: {e}"))
263 };
264 let month = u8::try_from(number(1)).unwrap();
265 time::Date::from_calendar_date(
266 number(0) as i32,
267 time::Month::try_from(month).unwrap(),
268 u8::try_from(number(2)).unwrap(),
269 )
270 .unwrap()
271 .with_hms(
272 u8::try_from(number(3)).unwrap(),
273 u8::try_from(number(4)).unwrap(),
274 u8::try_from(number(5)).unwrap(),
275 )
276 .unwrap()
277 .assume_utc()
278 }
279
280 /// The stamp on a rust log line must render its instant in UTC, not in
281 /// the host's local zone. The backend logs (backend.log, security.log)
282 /// come from Django with USE_TZ and are therefore UTC, and a local-zone
283 /// server.log skews against them by the zone offset - three hours on the
284 /// prod MSK hosts - so an incident timeline cannot be spliced across the
285 /// files without remembering the shift.
286 /// HYPOTHESIS: `log_timer()` builds its timer with
287 /// `UtcOffset::current_local_offset()`, so on this +07 dev host a line
288 /// stamped now renders now+7h and lands far off the UTC clock.
289 /// SOLUTION: render the stamp with `fmt::time::UtcTime` so every host,
290 /// whatever its local zone, logs UTC.
291 /// RESULT: the rendered stamp lands within two minutes of `now_utc()`; a
292 /// local-zone timer is a whole zone offset away (25200s here), which no
293 /// scheduling jitter can bridge.
294 /// STATUS: FIXED - see CLOUDPUB-1474
295 #[test]
296 fn log_timestamp_is_rendered_in_utc() {
297 let sink = LineSink::default();
298 let layer = fmt::Layer::default()
299 .with_timer(log_timer(LogZone::Utc).expect("failed to build log timer"))
300 .with_ansi(false)
301 .with_writer(sink.clone());
302 let subscriber = tracing_subscriber::registry().with(layer);
303
304 with_default(subscriber, || {
305 tracing::info!("timestamp zone probe");
306 });
307
308 let line = String::from_utf8(std::mem::take(&mut *sink.0.lock().unwrap()))
309 .expect("log line must be valid UTF-8");
310 let logged = stamp_as_utc(&line);
311 let skew =
312 (logged.unix_timestamp() - time::OffsetDateTime::now_utc().unix_timestamp()).abs();
313 assert!(
314 skew <= 120,
315 "log stamp {logged} is {skew}s away from UTC now - \
316 stamps must render UTC, got line: {line:?}"
317 );
318 }
319
320 /// The `log` crate's max level must follow the configured level, so that
321 /// `log::trace!`/`debug!` arguments are never evaluated above it.
322 ///
323 /// HYPOTHESIS: prod (server 3.5.1056, pingora-core 0.8.1) panicked three
324 /// times on 07.09 in the HTTP proxy worker - `Utf8Error { valid_up_to:
325 /// 153 }` out of `pingora-core-0.8.1/src/protocols/http/v1/client.rs:760`,
326 /// which is `trace!("Raw Response header: {:?}",
327 /// str::from_utf8(self.get_headers_raw()).unwrap())`. A `log` macro
328 /// evaluates its arguments whenever `log::max_level()` admits the record,
329 /// and `LogTracer::init()` sets that ceiling to Trace, leaving all
330 /// filtering to the tracing subscriber - so the unwrap ran on every
331 /// upstream response no matter how the server was configured, and any
332 /// upstream header carrying non-UTF-8 bytes (a cp1251 filename in
333 /// Content-Disposition) tore the request down.
334 /// SOLUTION: derive the ceiling from the same level string init_log
335 /// receives and hand it to `LogTracer::builder().with_max_level(..)`, so
336 /// the argument is not evaluated at all below it.
337 /// RESULT: red before the fix - `log_max_level` did not exist and the
338 /// module failed to compile; green once it does.
339 ///
340 /// The assertion stops at the pure function on purpose: `log::max_level()`
341 /// is process-global state set by `LogTracer::init`, and the suite shares
342 /// one process with one global subscriber, so an assertion on it after
343 /// `init_log` would depend on which test installed the logger first.
344 /// STATUS: FIXED - see CLOUDPUB-1507
345 #[test]
346 fn log_max_level_follows_configured_level() {
347 use log::LevelFilter;
348
349 assert_eq!(log_max_level("error"), LevelFilter::Error);
350 assert_eq!(log_max_level("warn"), LevelFilter::Warn);
351 assert_eq!(log_max_level("info"), LevelFilter::Info);
352 assert_eq!(log_max_level("debug"), LevelFilter::Debug);
353 assert_eq!(log_max_level("trace"), LevelFilter::Trace);
354 // "off" is a level the EnvFilter next door accepts too.
355 assert_eq!(log_max_level("off"), LevelFilter::Off);
356
357 // The level reaches init_log from a config file (server.toml
358 // log_level) or the --log-level flag, so it is free-form user input:
359 // parse it the way the tracing EnvFilter next to it does, which is
360 // case-insensitively. Getting this wrong would cap the `log` crate
361 // BELOW the configured level and silently drop records the operator
362 // asked for.
363 assert_eq!(log_max_level("INFO"), LevelFilter::Info);
364 assert_eq!(log_max_level("Trace"), LevelFilter::Trace);
365 assert_eq!(log_max_level(" debug "), LevelFilter::Debug);
366
367 // Anything unparseable falls back to Info rather than to Trace: the
368 // fallback must not be the one value that reinstates the panic.
369 assert_eq!(log_max_level("bogus"), LevelFilter::Info);
370 assert_eq!(log_max_level(""), LevelFilter::Info);
371 }
372}