Walk error source chains in tracing logs
tracing's %field formatter only calls Display on the top error, which
discards source() information. FennelError::Eval makes its Display the
filename alone, expecting miette to render the rest — so production
logs surfaced just a path with no diagnostic.
Assisted-by: Claude Opus 4.7 via Claude Code
diff --git a/src/bin/quire/main.rs b/src/bin/quire/main.rs
index 8e1af12..da0706f 100644
--- a/src/bin/quire/main.rs
+++ b/src/bin/quire/main.rs
@@ -6,6 +6,7 @@ use clap_complete::Shell;
use miette::IntoDiagnostic;
use miette::Result;
use quire::Quire;
+use quire::display_chain;
use sentry::ClientInitGuard;
use std::io::IsTerminal;
use tracing_subscriber::EnvFilter;
@@ -104,7 +105,7 @@ fn init_sentry(quire: &Quire) -> Option<ClientInitGuard> {
let config = match quire.global_config() {
Ok(config) => config,
Err(e) => {
- tracing::warn!(%e, "failed to load global config, skipping Sentry init");
+ tracing::warn!(error = %display_chain(&e), "failed to load global config, skipping Sentry init");
return None;
}
};
@@ -113,7 +114,7 @@ fn init_sentry(quire: &Quire) -> Option<ClientInitGuard> {
let dsn = match sentry_config.dsn.reveal() {
Ok(dsn) => dsn,
Err(e) => {
- tracing::warn!(%e, "failed to resolve Sentry DSN, skipping Sentry init");
+ tracing::warn!(error = %display_chain(&e), "failed to resolve Sentry DSN, skipping Sentry init");
return None;
}
};
diff --git a/src/bin/quire/server.rs b/src/bin/quire/server.rs
index 5aa90c6..d3884fa 100644
--- a/src/bin/quire/server.rs
+++ b/src/bin/quire/server.rs
@@ -6,6 +6,7 @@ use axum::routing::get;
use miette::{Context, IntoDiagnostic, Result};
use quire::Quire;
use quire::ci;
+use quire::display_chain;
use quire::event::PushEvent;
use quire::mirror;
@@ -73,7 +74,7 @@ async fn event_listener(listener: tokio::net::UnixListener, quire: Quire) {
tokio::spawn(handle_event_connection(stream, quire));
}
Err(e) => {
- tracing::error!(%e, "failed to accept event connection");
+ tracing::error!(error = %display_chain(&e), "failed to accept event connection");
}
}
}
@@ -90,7 +91,7 @@ async fn handle_event_connection(mut stream: tokio::net::UnixStream, quire: Quir
Ok(0) => return, // empty connection, ignore
Ok(_) => {}
Err(e) => {
- tracing::error!(%e, "failed to read event from socket");
+ tracing::error!(error = %display_chain(&e), "failed to read event from socket");
return;
}
}
@@ -98,7 +99,7 @@ async fn handle_event_connection(mut stream: tokio::net::UnixStream, quire: Quir
let event: PushEvent = match serde_json::from_str(&line) {
Ok(e) => e,
Err(e) => {
- tracing::error!(%e, "failed to parse push event");
+ tracing::error!(error = %display_chain(&e), "failed to parse push event");
return;
}
};
diff --git a/src/ci/mod.rs b/src/ci/mod.rs
index 59216e2..93b7e38 100644
--- a/src/ci/mod.rs
+++ b/src/ci/mod.rs
@@ -23,6 +23,7 @@ pub struct CommitRef {
use std::path::PathBuf;
use crate::Result;
+use crate::display_chain;
use crate::event::{PushEvent, PushRef};
use crate::quire::Repo;
@@ -109,7 +110,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
return;
}
Err(e) => {
- tracing::error!(repo = %event.repo, %e, "invalid repo name in event");
+ tracing::error!(repo = %event.repo, error = format!("{e:#}"), "invalid repo name in event");
return;
}
};
@@ -117,7 +118,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
let secrets = match quire.global_config() {
Ok(config) => config.secrets,
Err(e) => {
- tracing::error!(repo = %event.repo, %e, "failed to load global config");
+ tracing::error!(repo = %event.repo, error = %display_chain(&e), "failed to load global config");
return;
}
};
@@ -127,7 +128,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
tracing::error!(
repo = %event.repo,
sha = %push_ref.new_sha, // cov-excl-line
- %e,
+ error = %display_chain(&e),
"CI trigger failed"
);
}
diff --git a/src/ci/run.rs b/src/ci/run.rs
index d23c967..6c9e821 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -14,6 +14,7 @@ use mlua::IntoLua;
use super::lua::{Runtime, RuntimeHandle, ShOutput};
use super::pipeline::Pipeline;
+use crate::display_chain;
use crate::secret::SecretString;
use crate::{Error, Result};
@@ -136,7 +137,7 @@ impl Runs {
tracing::warn!(
state = ?state,
run_id = %name,
- %e,
+ error = %display_chain(&e),
"quarantining unreadable run to failed/"
);
self.quarantine(&state_path.join(&name), &name)?;
@@ -183,7 +184,7 @@ impl Runs {
if let Err(e) = orphan.transition(RunState::Complete) {
tracing::error!(
run_id = %orphan.id(), // cov-excl-line
- %e,
+ error = %display_chain(&e),
"failed to transition orphaned pending run"
);
}
@@ -196,7 +197,7 @@ impl Runs {
if let Err(e) = orphan.transition(RunState::Failed) {
tracing::error!(
run_id = %orphan.id(), // cov-excl-line
- %e,
+ error = %display_chain(&e),
"failed to transition orphaned active run to failed"
);
}
diff --git a/src/error.rs b/src/error.rs
index f7c7b3e..18b01b7 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -49,6 +49,39 @@ pub enum Error {
pub type Result<T> = std::result::Result<T, Error>;
+/// Display wrapper that walks an error's `source()` chain.
+///
+/// `tracing`'s `%field` formatter only calls `Display` on the top
+/// error, which discards information for layered errors (e.g.,
+/// `FennelError::Eval` whose top message is just the filename, with
+/// the real diagnostic carried by its `source`). This wrapper joins
+/// each layer with `": "` so structured logs carry the whole chain.
+///
+/// Construct via [`display_chain`] and use as a tracing field:
+///
+/// ```ignore
+/// tracing::error!(error = %display_chain(&e), "operation failed");
+/// ```
+pub struct DisplayChain<'a>(&'a (dyn std::error::Error + 'static));
+
+impl std::fmt::Display for DisplayChain<'_> {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}", self.0)?;
+ let mut cur = self.0.source();
+ while let Some(err) = cur {
+ write!(f, ": {err}")?;
+ cur = err.source();
+ }
+ Ok(())
+ }
+}
+
+/// Wrap an error reference for chained `Display` rendering. See
+/// [`DisplayChain`].
+pub fn display_chain<E: std::error::Error + 'static>(err: &E) -> DisplayChain<'_> {
+ DisplayChain(err)
+}
+
impl From<FennelError> for Error {
fn from(err: FennelError) -> Self {
Error::Fennel(Box::new(err))
@@ -75,6 +108,34 @@ mod tests {
assert!(err.to_string().contains("test.fnl"));
}
+ #[test]
+ fn display_chain_walks_source_chain() {
+ // FennelError::Eval has a top-level message of just the
+ // filename and an mlua::Error in its source — the exact case
+ // the helper is meant to fix.
+ let f = crate::fennel::Fennel::new().expect("Fennel::new");
+ let result: std::result::Result<i32, _> = f.load_string("(this is not valid", "bad.fnl");
+ let fennel_err = result.unwrap_err();
+
+ let plain = fennel_err.to_string();
+ let chained = display_chain(&fennel_err).to_string();
+
+ assert!(
+ chained.starts_with(&plain),
+ "chained output should begin with the top message"
+ );
+ assert!(
+ chained.len() > plain.len(),
+ "chained output should add source info: top={plain:?} chained={chained:?}"
+ );
+ }
+
+ #[test]
+ fn display_chain_handles_no_source() {
+ let err = Error::Git("boom".to_string());
+ assert_eq!(display_chain(&err).to_string(), "git error: boom");
+ }
+
#[test]
fn from_pipeline_error() {
let source = "(ci.job :a [] (fn [_] nil))";
diff --git a/src/lib.rs b/src/lib.rs
index 5c8ea59..0363fd2 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -8,4 +8,5 @@ pub mod secret;
pub use error::Error;
pub use error::Result;
+pub use error::display_chain;
pub use quire::Quire;
diff --git a/src/mirror.rs b/src/mirror.rs
index ee86562..e938ce9 100644
--- a/src/mirror.rs
+++ b/src/mirror.rs
@@ -2,6 +2,7 @@
//! Mirror push: replicate ref updates to a configured remote.
use crate::Quire;
+use crate::display_chain;
use crate::event::PushEvent;
use crate::quire::{MirrorConfig, Repo};
@@ -18,7 +19,7 @@ pub async fn push(quire: &Quire, event: &PushEvent) {
return;
}
Err(e) => {
- tracing::error!(repo = %event.repo, %e, "invalid repo name in event");
+ tracing::error!(repo = %event.repo, error = format!("{e:#}"), "invalid repo name in event");
return;
}
};
@@ -26,7 +27,7 @@ pub async fn push(quire: &Quire, event: &PushEvent) {
let config = match repo.config() {
Ok(c) => c,
Err(e) => {
- tracing::error!(repo = %event.repo, %e, "failed to load repo config");
+ tracing::error!(repo = %event.repo, error = %display_chain(&e), "failed to load repo config");
return;
}
};
@@ -39,7 +40,7 @@ pub async fn push(quire: &Quire, event: &PushEvent) {
let global_config = match quire.global_config() {
Ok(c) => c,
Err(e) => {
- tracing::error!(%e, "failed to load global config for mirror push");
+ tracing::error!(error = %display_chain(&e), "failed to load global config for mirror push");
return;
}
};
@@ -47,7 +48,7 @@ pub async fn push(quire: &Quire, event: &PushEvent) {
let token = match global_config.github.token.reveal() {
Ok(t) => t.to_string(),
Err(e) => {
- tracing::error!(%e, "failed to resolve GitHub token");
+ tracing::error!(error = %display_chain(&e), "failed to resolve GitHub token");
return;
}
};
@@ -70,8 +71,12 @@ pub async fn push(quire: &Quire, event: &PushEvent) {
match result {
Ok(Ok(())) => tracing::info!(url = %mirror_url, "mirror push complete"),
- Ok(Err(e)) => tracing::error!(url = %mirror_url, %e, "mirror push failed"),
- Err(e) => tracing::error!(url = %mirror_url, %e, "mirror push task panicked"),
+ Ok(Err(e)) => {
+ tracing::error!(url = %mirror_url, error = %display_chain(&e), "mirror push failed")
+ }
+ Err(e) => {
+ tracing::error!(url = %mirror_url, error = %display_chain(&e), "mirror push task panicked")
+ }
}
}