crates/gunnar-common/src/stderr_capture.rs
//! **What a library prints on stderr becomes a log line** (feature
//! `stderr-capture`, 2026-09-28).
//!
//! # Why
//!
//! MEASURED 2026-09-28 on test2's front (vm1, gunnar 50c3e69) through the
//! installer's log door: a tenant's order (`subscription.bought … state=
//! "pending"`) and then NOTHING from monetize — no checkout creation, no
//! Stripe call, no webhook, no poller pass, no capture. monetize-server runs
//! IN the front's process (`gunnar_front::monetize::serve_thread`) and says
//! everything with `eprintln!("monetize-server: …")` (≈100 call sites in
//! monetize-server alone). `eprintln!` writes fd 2 — on PID 1 the serial
//! console — and never passes tracing, so none of it reached the boot log the
//! installer reads (`installer_listener::BOOT_LOG`, fed by the logqueue tap).
//!
//! # What this does
//!
//! [`start`] is called at the top of `main`, BEFORE tracing is installed:
//!
//! 1. the console fd 2 was is duplicated and named as the drainer's sink
//! ([`crate::logqueue::set_console`]) — tracing keeps writing to the real
//! console, never into the pipe below;
//! 2. a pipe replaces fd 2 (`dup2`), so every `eprintln!`/`eprint!` in the
//! process — monetize-server, its wasm guests' stderr, any crate — lands in
//! the pipe;
//! 3. one thread reads the pipe line by line and says each line through
//! `tracing` under target `stderr` — so it gets a timestamp and a level,
//! reaches the console through the queue, the dashboard's tap, and the
//! boot log's tap, where the VM's redaction (nordisk-pairing `redact`)
//! withholds what must never leave the box.
//!
//! A line that says it failed (`FAILED`, `REFUSED`, `panicked`, ` error`) is
//! a `WARN` — never dropped by a full console queue; the rest are `INFO`.
use std::os::fd::AsFd as _;
/// The tracing target stderr lines are said under.
pub const TARGET: &str = "stderr";
/// The level a captured line is said at: `WARN` when it says it failed.
pub fn is_warning(line: &str) -> bool {
line.contains("FAILED")
|| line.contains("REFUSED")
|| line.contains("panicked")
|| line.to_ascii_lowercase().contains(" error")
}
/// Say one captured line through tracing.
pub fn say(line: &str) {
let line = line.trim_end_matches(['\r', '\n']);
if line.trim().is_empty() {
return;
}
if is_warning(line) {
tracing::warn!(target: "stderr", "{line}");
} else {
tracing::info!(target: "stderr", "{line}");
}
}
/// **Take fd 2 over** — see the module doc. Returns an error, and changes
/// nothing, when the console cannot be duplicated or the pipe cannot be made;
/// the caller says so and carries on with stderr as it was. Once per process.
pub fn start() -> std::io::Result<()> {
static STARTED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
if STARTED.get().is_some() {
return Ok(());
}
let console: std::fs::File = std::io::stderr().as_fd().try_clone_to_owned()?.into();
let (read, write) = rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC)?;
let reader = std::io::BufReader::new(std::fs::File::from(read));
// A32-14, stays a task: the pipe's reader for the life of fd 2; its start failure is the caller's answer.
std::thread::Builder::new()
.name("stderr-capture".into())
.spawn(move || pump(reader))?;
crate::logqueue::set_console(console);
rustix::stdio::dup2_stderr(&write)?;
drop(write);
let _ = STARTED.set(());
Ok(())
}
/// The reader: one line at a time, lossy UTF-8, until the pipe closes.
pub fn pump(mut reader: impl std::io::BufRead) {
let mut buf = Vec::with_capacity(1024);
loop {
buf.clear();
match reader.read_until(b'\n', &mut buf) {
Ok(0) | Err(_) => return,
Ok(_) => say(&String::from_utf8_lossy(&buf)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_line_that_says_it_failed_is_a_warning() {
assert!(is_warning(
"monetize-server: grow FAILED: the cloud said no"
));
assert!(is_warning(
"monetize-server: entitlement push REFUSED for gunnar/alice, not retried"
));
assert!(is_warning(
"monetize-server: poller panicked: index out of bounds"
));
assert!(!is_warning(
"monetize-server: listening on 127.0.0.1:50061 (gRPC h2c + POST /callback/<vendor>)"
));
}
}