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
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)
+}