commit 4b00c138694dabbfab83b6c56adf43f4333ca515
parent bf25385982a2d8cf6f0f1d6471fb8221d7c16700
Author: triesap <tyson@radroots.org>
Date: Fri, 17 Jul 2026 22:00:08 +0000
log: support structured stderr output
- add stderr as an explicit canonical logging sink
- preserve stdout for machine-readable command results
- validate stderr-only structured configurations
- document structured console sink behavior
Diffstat:
3 files changed, 25 insertions(+), 3 deletions(-)
diff --git a/crates/log/README b/crates/log/README
@@ -8,7 +8,7 @@ primitives for the `radroots` core libraries.
* basic `log_info`, `log_error`, and `log_debug` helpers available across
builds;
* typed error and result definitions for logging initialization paths;
- * `std` initializers for default, structured stdout, and file-backed tracing;
+ * `std` initializers for default, structured stdout/stderr, and file-backed tracing;
* service, run, and environment identity on every structured JSON event;
* fail-closed initialization when a different global configuration is active;
* size-bounded files with deterministic retention and private file modes;
diff --git a/crates/log/src/init.rs b/crates/log/src/init.rs
@@ -51,10 +51,14 @@ pub fn init_logging(opts: LoggingOptions) -> Result<()> {
let stdout_layer = opts
.also_stdout()
.then(|| fmt::layer().with_writer(std::io::stdout).with_target(false));
+ let stderr_layer = opts
+ .stderr
+ .then(|| fmt::layer().with_writer(std::io::stderr).with_target(false));
tracing_subscriber::registry()
.with(filter)
.with(file_layer)
.with(stdout_layer)
+ .with(stderr_layer)
.try_init()?;
}
LogFormat::Json => {
@@ -71,14 +75,21 @@ pub fn init_logging(opts: LoggingOptions) -> Result<()> {
});
let stdout_layer = opts.also_stdout().then(|| {
fmt::layer()
- .event_format(JsonEventFormatter::new(identity))
+ .event_format(JsonEventFormatter::new(identity.clone()))
.with_writer(std::io::stdout)
.with_ansi(false)
});
+ let stderr_layer = opts.stderr.then(|| {
+ fmt::layer()
+ .event_format(JsonEventFormatter::new(identity))
+ .with_writer(std::io::stderr)
+ .with_ansi(false)
+ });
tracing_subscriber::registry()
.with(filter)
.with(file_layer)
.with(stdout_layer)
+ .with(stderr_layer)
.try_init()?;
}
}
@@ -90,6 +101,7 @@ pub fn init_logging(opts: LoggingOptions) -> Result<()> {
info!(
file_enabled = file_path.is_some(),
stdout_enabled = opts.also_stdout(),
+ stderr_enabled = opts.stderr,
"logging initialized"
);
Ok(())
@@ -153,7 +165,7 @@ fn build_file_writer(
}
fn validate_options(opts: &LoggingOptions) -> Result<()> {
- if opts.dir.is_none() && !opts.stdout {
+ if opts.dir.is_none() && !opts.stdout && !opts.stderr {
return Err(Error::Msg(
"logging requires at least one configured output".to_owned(),
));
@@ -269,6 +281,14 @@ mod tests {
}
#[test]
+ fn stderr_is_a_valid_standalone_sink() {
+ let mut options = LoggingOptions::json_service("service", "run-1", "production");
+ options.stdout = false;
+ options.stderr = true;
+ assert!(validate_options(&options).is_ok());
+ }
+
+ #[test]
fn repeated_initialization_reuses_only_an_identical_configuration() {
let active = LoggingOptions::json_service("service", "run-1", "test");
assert!(initialization_decision(Some(&active), &active).expect("reuse"));
diff --git a/crates/log/src/options.rs b/crates/log/src/options.rs
@@ -58,6 +58,7 @@ pub struct LoggingOptions {
pub dir: Option<PathBuf>,
pub file_name: String,
pub stdout: bool,
+ pub stderr: bool,
pub default_level: Option<String>,
pub file_layout: LogFileLayout,
pub format: LogFormat,
@@ -106,6 +107,7 @@ impl Default for LoggingOptions {
dir: None,
file_name: "radroots.log".into(),
stdout: true,
+ stderr: false,
default_level: None,
file_layout: LogFileLayout::PrefixedDate,
format: LogFormat::Compact,