Skip to main content

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}