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
change kknoqysuusoqslyyuyqzpmzzuynvxtpm
commit ed1f3a7c5d89c93878514a539bce00b5882ed706
author Alpha Chen <alpha@kejadlen.dev>
date
parent sxuumknw
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")
+        }
     }
 }