Add MietteLayer: thread-local sentry bridge for miette source snippets
Introduces quire-telemetry crate with two components:

- MietteLayer — a tracing_subscriber::Layer that intercepts record_error
  calls, walks the source chain trying registered concrete types, and
  renders matching errors via NarratableReportHandler into a thread-local.
  Registered with .with_type::<T>() for each top-level error type; miette's
  #[diagnostic(transparent)] delegation means registering the outermost
  wrapper is sufficient to surface inner FennelError source annotations.

- before_send — a sentry ClientOptions hook that reads and clears the
  thread-local, attaching the rendered diagnostic to extra["diagnostic"]
  on the Sentry event. Runs synchronously inside capture_event, so the
  thread-local is always consumed by the event that set it.

Layer ordering: MietteLayer is added after sentry_tracing::layer() in the
.with() chain, so it fires first and sets the thread-local before
sentry-tracing's on_event calls capture_event.

Callsite change: error = %err -> error = &err as &(dyn Error + 'static)
to trigger record_error (gives sentry-tracing an exception chain) instead
of record_str (gives only a Display string). One callsite in ci/mod.rs
stays as %e because quire.repo() returns miette::Report which does not
implement std::error::Error.

https://claude.ai/code/session_016ieFpQg1FfFhs6meg1aRom
change
commit 63ecad01a845f7a532279ee71f1393077c669fc0
author Claude <noreply@anthropic.com>
date
parent 20e521d8
diff --git a/Cargo.toml b/Cargo.toml
index c1877af..89e2bc0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,5 @@
 [workspace]
-members = ["quire-ci", "quire-core", "quire-server"]
+members = ["quire-ci", "quire-core", "quire-server", "quire-telemetry"]
 resolver = "3"
 
 [workspace.dependencies]
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index a607d29..179c59f 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -10,6 +10,7 @@ jiff = { workspace = true }
 miette = { workspace = true, features = ["fancy"] }
 mlua = { workspace = true }
 quire-core = { path = "../quire-core" }
+quire-telemetry = { path = "../quire-telemetry" }
 sentry = { workspace = true }
 sentry-tracing = { workspace = true }
 serde = { workspace = true }
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 52b06fb..3242207 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -211,7 +211,10 @@ fn main() -> miette::Result<()> {
             // Drop order: `_sentry` flushes first (still inside the
             // runtime), then `_enter`, then `rt`.
             let _sentry = init_sentry(sentry_handoff.as_ref(), &meta);
-            init_tracing()?;
+            let miette_layer = quire_telemetry::MietteLayer::new()
+                .with_type::<JobError>()
+                .with_type::<quire_core::fennel::FennelError>();
+            init_tracing(miette_layer)?;
 
             run_pipeline(cli.workspace, sink, log_dir, git_dir, meta, secrets)
         }
@@ -234,6 +237,7 @@ fn init_sentry(
         handoff.dsn.as_str(),
         sentry::ClientOptions {
             release: Some(VERSION.into()),
+            before_send: Some(std::sync::Arc::new(quire_telemetry::before_send)),
             ..Default::default()
         },
     ));
@@ -268,7 +272,7 @@ fn init_sentry(
 /// Initialize tracing with a stderr fmt layer plus the sentry-tracing
 /// bridge so `tracing::error!` (and warn, if configured) events show
 /// up in Sentry alongside panics.
-fn init_tracing() -> miette::Result<()> {
+fn init_tracing(miette_layer: quire_telemetry::MietteLayer) -> miette::Result<()> {
     let filter = EnvFilter::builder()
         .with_env_var("QUIRE_LOG")
         .from_env()
@@ -276,6 +280,7 @@ fn init_tracing() -> miette::Result<()> {
 
     tracing_subscriber::registry()
         .with(sentry_tracing::layer())
+        .with(miette_layer)
         .with(fmt::layer().with_writer(std::io::stderr))
         .with(filter)
         .init();
@@ -475,7 +480,7 @@ fn run_pipeline(
         // the failure (terminal output is handled by miette via the
         // returned `Err`). `%err` carries the full diagnostic now
         // that error Display impls are self-contained.
-        tracing::error!(job = %job_id, error = %err, "job run-fn failed");
+        tracing::error!(job = %job_id, error = &err as &(dyn std::error::Error + 'static), "job run-fn failed");
         return Err(err.into());
     }
 
diff --git a/quire-server/Cargo.toml b/quire-server/Cargo.toml
index 13f1a25..a9c6d5a 100644
--- a/quire-server/Cargo.toml
+++ b/quire-server/Cargo.toml
@@ -17,6 +17,7 @@ path = "src/bin/quire/main.rs"
 
 [dependencies]
 quire-core = { path = "../quire-core" }
+quire-telemetry = { path = "../quire-telemetry" }
 
 fs-err = { workspace = true, features = ["tokio"] }
 jiff = { workspace = true }
diff --git a/quire-server/src/bin/quire/main.rs b/quire-server/src/bin/quire/main.rs
index cc6d474..44f741e 100644
--- a/quire-server/src/bin/quire/main.rs
+++ b/quire-server/src/bin/quire/main.rs
@@ -113,7 +113,7 @@ fn init_sentry(quire: &Quire) -> Option<ClientInitGuard> {
     let config = match quire.global_config() {
         Ok(config) => config,
         Err(e) => {
-            tracing::warn!(error = %e, "failed to load global config, skipping Sentry init");
+            tracing::warn!(error = &e as &(dyn std::error::Error + 'static), "failed to load global config, skipping Sentry init");
             return None;
         }
     };
@@ -122,7 +122,7 @@ fn init_sentry(quire: &Quire) -> Option<ClientInitGuard> {
     let dsn = match sentry_config.dsn.reveal() {
         Ok(dsn) => dsn,
         Err(e) => {
-            tracing::warn!(error = %e, "failed to resolve Sentry DSN, skipping Sentry init");
+            tracing::warn!(error = &e as &(dyn std::error::Error + 'static), "failed to resolve Sentry DSN, skipping Sentry init");
             return None;
         }
     };
@@ -131,6 +131,7 @@ fn init_sentry(quire: &Quire) -> Option<ClientInitGuard> {
         dsn,
         sentry::ClientOptions {
             release: Some(VERSION.into()),
+            before_send: Some(std::sync::Arc::new(quire_telemetry::before_send)),
             ..Default::default()
         },
     ));
@@ -142,7 +143,7 @@ fn init_sentry(quire: &Quire) -> Option<ClientInitGuard> {
 ///
 /// Emits structured JSON when stderr is not a terminal (e.g. piped to a log
 /// collector), and human-readable text when running interactively.
-fn init_tracing() -> Result<()> {
+fn init_tracing(miette_layer: quire_telemetry::MietteLayer) -> Result<()> {
     let filter = EnvFilter::builder()
         .with_env_var("QUIRE_LOG")
         .from_env()
@@ -157,6 +158,7 @@ fn init_tracing() -> Result<()> {
 
     tracing_subscriber::registry()
         .with(sentry_tracing::layer())
+        .with(miette_layer)
         .with(fmt_layer)
         .with(filter)
         .init();
@@ -173,7 +175,11 @@ async fn main() -> Result<()> {
         None => Quire::default(),
     };
     let _sentry = init_sentry(&quire);
-    init_tracing()?;
+    let miette_layer = quire_telemetry::MietteLayer::new()
+        .with_type::<quire::Error>()
+        .with_type::<quire::ci::Error>()
+        .with_type::<quire_core::fennel::FennelError>();
+    init_tracing(miette_layer)?;
 
     if let Some(shell) = cli.completions {
         clap_complete::generate(shell, &mut Cli::command(), "quire", &mut std::io::stdout());
diff --git a/quire-server/src/bin/quire/server.rs b/quire-server/src/bin/quire/server.rs
index c0b823d..975f842 100644
--- a/quire-server/src/bin/quire/server.rs
+++ b/quire-server/src/bin/quire/server.rs
@@ -78,7 +78,7 @@ async fn event_listener(listener: tokio::net::UnixListener, quire: Quire) {
                 tokio::spawn(handle_event_connection(stream, quire));
             }
             Err(e) => {
-                tracing::error!(error = %e, "failed to accept event connection");
+                tracing::error!(error = &e as &(dyn std::error::Error + 'static), "failed to accept event connection");
             }
         }
     }
@@ -95,7 +95,7 @@ async fn handle_event_connection(mut stream: tokio::net::UnixStream, quire: Quir
         Ok(0) => return, // empty connection, ignore
         Ok(_) => {}
         Err(e) => {
-            tracing::error!(error = %e, "failed to read event from socket");
+            tracing::error!(error = &e as &(dyn std::error::Error + 'static), "failed to read event from socket");
             return;
         }
     }
@@ -103,7 +103,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!(error = %e, "failed to parse push event");
+            tracing::error!(error = &e as &(dyn std::error::Error + 'static), "failed to parse push event");
             return;
         }
     };
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 943e822..c6a0ff8 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -114,7 +114,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
             return;
         }
         Err(e) => {
-            tracing::error!(repo = %event.repo, error = format!("{e:#}"), "invalid repo name in event");
+            tracing::error!(repo = %event.repo, error = %e, "invalid repo name in event");
             return;
         }
     };
@@ -122,7 +122,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
     let config = match quire.global_config() {
         Ok(config) => config,
         Err(e) => {
-            tracing::error!(repo = %event.repo, error = %e, "failed to load global config");
+            tracing::error!(repo = %event.repo, error = &e as &(dyn std::error::Error + 'static), "failed to load global config");
             return;
         }
     };
@@ -131,7 +131,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
         Ok(dsn) => Some(dsn.to_string()),
         Err(e) => {
             tracing::warn!(
-                error = %e,
+                error = &e as &(dyn std::error::Error + 'static),
                 "failed to resolve Sentry DSN, quire-ci runs will skip Sentry",
             );
             None
@@ -182,7 +182,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
                     tracing::error!(
                         repo = %event.repo,
                         sha = %push_ref.new_sha, // cov-excl-line
-                        error = %e,
+                        error = &e as &(dyn std::error::Error + 'static),
                         "CI trigger failed"
                     );
                 }
diff --git a/quire-server/src/quire/web/handlers.rs b/quire-server/src/quire/web/handlers.rs
index 3ac318f..9c933a2 100644
--- a/quire-server/src/quire/web/handlers.rs
+++ b/quire-server/src/quire/web/handlers.rs
@@ -26,7 +26,7 @@ fn render<T: Template>(tmpl: &T) -> Response {
     match tmpl.render() {
         Ok(body) => Html(body).into_response(),
         Err(e) => {
-            tracing::error!(error = %e, "template render failed");
+            tracing::error!(error = &e as &(dyn std::error::Error + 'static), "template render failed");
             (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
         }
     }
@@ -56,7 +56,7 @@ async fn read_log(path: &std::path::Path) -> String {
         Ok(content) => content,
         Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
         Err(e) => {
-            tracing::warn!(path = %path.display(), error = %e, "failed to read CRI log");
+            tracing::warn!(path = %path.display(), error = &e as &(dyn std::error::Error + 'static), "failed to read CRI log");
             String::new()
         }
     }
@@ -74,7 +74,7 @@ fn render_error(repo: String, status: StatusCode, title: &str, detail: String) -
     match tmpl.render() {
         Ok(body) => (status, Html(body)).into_response(),
         Err(e) => {
-            tracing::error!(error = %e, "error template render failed");
+            tracing::error!(error = &e as &(dyn std::error::Error + 'static), "error template render failed");
             (status, format!("{title}\n\n{detail}\n")).into_response()
         }
     }
@@ -92,7 +92,7 @@ pub async fn run_list(State(quire): State<Quire>, AxumPath(repo): AxumPath<Strin
     let runs = match tokio::task::spawn_blocking(move || db::load_runs(&q, &repo_name)).await {
         Ok(Ok(r)) => r,
         Ok(Err(e)) => {
-            tracing::error!(repo = %repo, error = %e, "failed to load runs");
+            tracing::error!(repo = %repo, error = &e as &(dyn std::error::Error + 'static), "failed to load runs");
             return render_error(
                 repo_display,
                 StatusCode::INTERNAL_SERVER_ERROR,
@@ -149,7 +149,7 @@ pub async fn run_detail(
         Ok(Ok(d)) => d,
         Ok(Err(ref e)) if is_no_rows(e) => return StatusCode::NOT_FOUND.into_response(),
         Ok(Err(e)) => {
-            tracing::error!(repo = %repo, run_id = %run_id, error = %e, "failed to load run detail");
+            tracing::error!(repo = %repo, run_id = %run_id, error = &e as &(dyn std::error::Error + 'static), "failed to load run detail");
             return render_error(
                 repo_display,
                 StatusCode::INTERNAL_SERVER_ERROR,
diff --git a/quire-telemetry/Cargo.toml b/quire-telemetry/Cargo.toml
new file mode 100644
index 0000000..987ee1c
--- /dev/null
+++ b/quire-telemetry/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "quire-telemetry"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+miette = { workspace = true }
+sentry = { workspace = true }
+serde_json = { workspace = true }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
diff --git a/quire-telemetry/src/lib.rs b/quire-telemetry/src/lib.rs
new file mode 100644
index 0000000..b3226ce
--- /dev/null
+++ b/quire-telemetry/src/lib.rs
@@ -0,0 +1,149 @@
+use std::cell::RefCell;
+use std::error::Error;
+use std::sync::Arc;
+
+thread_local! {
+    static MIETTE_RENDER: RefCell<Option<String>> = const { RefCell::new(None) };
+}
+
+type RenderFn = Box<dyn (Fn(&(dyn Error + 'static)) -> Option<String>) + Send + Sync>;
+
+/// A [`tracing_subscriber::Layer`] that intercepts `record_error` calls,
+/// renders the error as a naratable miette diagnostic, and stashes the
+/// result in a thread-local for [`before_send`] to attach to the Sentry event.
+///
+/// Register concrete error types with [`MietteLayer::with_type`]. The layer
+/// walks the full source chain at each registered type, so transparent wrapper
+/// errors don't need separate registration — registering the outermost type is
+/// sufficient when it carries `#[diagnostic(transparent)]`.
+///
+/// # Layer ordering
+///
+/// Add this layer **after** `sentry_tracing::layer()` in the `.with()` chain
+/// so it fires first and sets the thread-local before sentry-tracing's
+/// `on_event` calls `capture_event` (which invokes `before_send` synchronously).
+///
+/// ```ignore
+/// tracing_subscriber::registry()
+///     .with(sentry_tracing::layer())
+///     .with(miette_layer)   // fires first — sets thread-local
+///     .with(fmt_layer)
+///     .with(filter)
+///     .init();
+/// ```
+pub struct MietteLayer {
+    renderers: Arc<Vec<RenderFn>>,
+}
+
+impl Default for MietteLayer {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl MietteLayer {
+    pub fn new() -> Self {
+        Self {
+            renderers: Arc::new(Vec::new()),
+        }
+    }
+
+    /// Register a concrete error type for miette rendering.
+    ///
+    /// When an error field is recorded via `record_error`, the layer tries
+    /// `downcast_ref::<T>` at each level of the source chain. The first match
+    /// is rendered with [`miette::NarratableReportHandler`] and stashed in the
+    /// thread-local for [`before_send`] to attach.
+    pub fn with_type<T>(mut self) -> Self
+    where
+        T: miette::Diagnostic + 'static,
+    {
+        Arc::get_mut(&mut self.renderers)
+            .expect("no other Arc refs at construction time")
+            .push(Box::new(|err: &(dyn Error + 'static)| {
+                let mut cur: Option<&(dyn Error + 'static)> = Some(err);
+                while let Some(e) = cur {
+                    if let Some(diag) = e.downcast_ref::<T>() {
+                        let mut buf = String::new();
+                        if miette::NarratableReportHandler::new()
+                            .render_report(&mut buf, diag)
+                            .is_ok()
+                            && !buf.trim().is_empty()
+                        {
+                            return Some(buf);
+                        }
+                    }
+                    cur = e.source();
+                }
+                None
+            }));
+        self
+    }
+
+    fn try_render(&self, err: &(dyn Error + 'static)) -> Option<String> {
+        self.renderers.iter().find_map(|r| r(err))
+    }
+}
+
+struct ErrorVisitor<'a> {
+    layer: &'a MietteLayer,
+}
+
+impl tracing::field::Visit for ErrorVisitor<'_> {
+    fn record_error(
+        &mut self,
+        _field: &tracing::field::Field,
+        value: &(dyn Error + 'static),
+    ) {
+        if let Some(rendered) = self.layer.try_render(value) {
+            MIETTE_RENDER.with(|cell| *cell.borrow_mut() = Some(rendered));
+        }
+    }
+
+    fn record_debug(&mut self, _: &tracing::field::Field, _: &dyn std::fmt::Debug) {}
+}
+
+impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for MietteLayer {
+    fn on_event(
+        &self,
+        event: &tracing::Event<'_>,
+        _ctx: tracing_subscriber::layer::Context<'_, S>,
+    ) {
+        // Clear stale data first — handles the case where a previous error
+        // was not captured by Sentry (e.g. a WARN that becomes a breadcrumb
+        // rather than an event, so before_send never fires to clear it).
+        MIETTE_RENDER.with(|cell| *cell.borrow_mut() = None);
+
+        if *event.metadata().level() > tracing::Level::WARN {
+            return;
+        }
+
+        let mut visitor = ErrorVisitor { layer: self };
+        event.record(&mut visitor);
+    }
+}
+
+/// Sentry `before_send` hook: reads the thread-local miette rendering and
+/// attaches it to `extra["diagnostic"]` before the event is sent.
+///
+/// Install at Sentry init time:
+///
+/// ```ignore
+/// sentry::init((dsn, sentry::ClientOptions {
+///     before_send: Some(std::sync::Arc::new(quire_telemetry::before_send)),
+///     ..Default::default()
+/// }));
+/// ```
+///
+/// The hook consumes the thread-local so each event gets at most one attachment
+/// and stale data from un-captured events is cleaned up automatically.
+pub fn before_send(
+    mut event: sentry::protocol::Event<'static>,
+) -> Option<sentry::protocol::Event<'static>> {
+    if let Some(rendered) = MIETTE_RENDER.with(|cell| cell.borrow_mut().take()) {
+        event
+            .extra
+            .insert("diagnostic".into(), serde_json::Value::String(rendered));
+    }
+    Some(event)
+}