Extract tracing+sentry init helpers into quire-core
Both binaries built the same tracing subscriber chain and Sentry
ClientOptions by hand. After fixing the MietteLayer ordering bug the
two copies need to stay in lockstep — the wrong .with() order silently
swallows the diagnostic attachment again.
Move the shared assembly into quire-core::telemetry:
- init_tracing(miette_layer, fmt_mode) bakes in the correct layer
order and the QUIRE_LOG env filter. FmtMode::AutoJson preserves
quire-server's TTY-aware JSON output; FmtMode::Plain matches
quire-ci's current behavior.
- sentry_client_options(release) returns ClientOptions with the
before_send hook pre-wired so each binary only has to supply the
DSN.
Move tracing-subscriber's "json" feature and the sentry-tracing
dependency up to quire-core (and the workspace), so the binaries no
longer need to know about either crate directly.
Assisted-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
diff --git a/Cargo.lock b/Cargo.lock
index 5378367..5888d7a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2134,14 +2134,12 @@ dependencies = [
"mlua",
"quire-core",
"sentry",
- "sentry-tracing",
"serde",
"serde_json",
"tempfile",
"thiserror",
"tokio",
"tracing",
- "tracing-subscriber",
]
[[package]]
@@ -2155,6 +2153,7 @@ dependencies = [
"petgraph",
"regex",
"sentry",
+ "sentry-tracing",
"serde",
"serde_json",
"tempfile",
@@ -2185,7 +2184,6 @@ dependencies = [
"rusqlite",
"rusqlite_migration",
"sentry",
- "sentry-tracing",
"serde",
"serde_json",
"serde_yaml_ng",
@@ -2195,7 +2193,6 @@ dependencies = [
"tokio",
"tower",
"tracing",
- "tracing-subscriber",
"uuid",
"walkdir",
]
diff --git a/Cargo.toml b/Cargo.toml
index c1877af..71040d8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,4 +18,4 @@ tempfile = "*"
thiserror = "*"
tokio = "*"
tracing = "*"
-tracing-subscriber = { version = "*", features = ["env-filter"] }
+tracing-subscriber = { version = "*", features = ["env-filter", "json"] }
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index a607d29..2392f00 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -11,11 +11,9 @@ miette = { workspace = true, features = ["fancy"] }
mlua = { workspace = true }
quire-core = { path = "../quire-core" }
sentry = { workspace = true }
-sentry-tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread"] }
tracing = { workspace = true }
-tracing-subscriber = { workspace = true }
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index b8d4201..c9be6b5 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -13,7 +13,7 @@ use quire_core::ci::pipeline::{self, Pipeline, RunFn};
use quire_core::ci::run::RunMeta;
use quire_core::ci::runtime::{Runtime, RuntimeError, RuntimeEvent, RuntimeHandle};
use quire_core::fennel::FennelError;
-use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
+use quire_core::telemetry::{self, FmtMode, MietteLayer};
/// Errors from running a job's `run_fn`. Lua errors are re-wrapped
/// via [`FennelError::from_lua`] so they carry the same source-code
@@ -211,10 +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);
- let miette_layer = quire_core::telemetry::MietteLayer::new()
+ let miette_layer = MietteLayer::new()
.with_type::<JobError>()
.with_type::<quire_core::fennel::FennelError>();
- init_tracing(miette_layer)?;
+ telemetry::init_tracing(miette_layer, FmtMode::Plain)?;
run_pipeline(cli.workspace, sink, log_dir, git_dir, meta, secrets)
}
@@ -235,11 +235,7 @@ fn init_sentry(
let handoff = handoff?;
let guard = sentry::init((
handoff.dsn.as_str(),
- sentry::ClientOptions {
- release: Some(VERSION.into()),
- before_send: Some(std::sync::Arc::new(quire_core::telemetry::before_send)),
- ..Default::default()
- },
+ telemetry::sentry_client_options(VERSION),
));
sentry::configure_scope(|scope| {
scope.set_tag("service", "quire-ci");
@@ -269,25 +265,6 @@ fn init_sentry(
Some(guard)
}
-/// 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_layer: quire_core::telemetry::MietteLayer) -> miette::Result<()> {
- let filter = EnvFilter::builder()
- .with_env_var("QUIRE_LOG")
- .from_env()
- .into_diagnostic()?;
-
- tracing_subscriber::registry()
- .with(miette_layer)
- .with(sentry_tracing::layer())
- .with(fmt::layer().with_writer(std::io::stderr))
- .with(filter)
- .init();
-
- Ok(())
-}
-
fn validate(workspace: PathBuf) -> miette::Result<()> {
let pipeline = compile_at(&workspace)?;
diff --git a/quire-core/Cargo.toml b/quire-core/Cargo.toml
index d83c075..8331eab 100644
--- a/quire-core/Cargo.toml
+++ b/quire-core/Cargo.toml
@@ -11,6 +11,7 @@ mlua = { workspace = true }
petgraph = { workspace = true }
regex = { workspace = true }
sentry = { workspace = true }
+sentry-tracing = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
diff --git a/quire-core/src/telemetry.rs b/quire-core/src/telemetry.rs
index 2b73fa4..fcb8b9f 100644
--- a/quire-core/src/telemetry.rs
+++ b/quire-core/src/telemetry.rs
@@ -1,7 +1,14 @@
use std::cell::RefCell;
use std::error::Error;
+use std::io::IsTerminal;
use std::sync::Arc;
+use miette::IntoDiagnostic;
+use tracing_subscriber::EnvFilter;
+use tracing_subscriber::Layer;
+use tracing_subscriber::layer::SubscriberExt;
+use tracing_subscriber::util::SubscriberInitExt;
+
thread_local! {
static MIETTE_RENDER: RefCell<Option<String>> = const { RefCell::new(None) };
}
@@ -146,11 +153,67 @@ pub fn before_send(
Some(event)
}
+/// How the stderr fmt layer formats events.
+pub enum FmtMode {
+ /// Human-readable text on stderr.
+ Plain,
+ /// Plain text when stderr is a TTY, JSON otherwise — so a console user
+ /// gets a readable stream while a log collector capturing the pipe gets
+ /// structured output.
+ AutoJson,
+}
+
+/// Common Sentry [`ClientOptions`] with the [`before_send`] hook pre-wired
+/// to attach miette renderings.
+///
+/// [`ClientOptions`]: sentry::ClientOptions
+pub fn sentry_client_options(release: &'static str) -> sentry::ClientOptions {
+ sentry::ClientOptions {
+ release: Some(release.into()),
+ before_send: Some(Arc::new(before_send)),
+ ..Default::default()
+ }
+}
+
+/// Initialize the global tracing subscriber with `QUIRE_LOG`-driven filtering,
+/// a stderr fmt layer per `fmt_mode`, the `sentry-tracing` bridge, and the
+/// supplied [`MietteLayer`].
+///
+/// Layer ordering is baked in: `miette_layer` registers before
+/// `sentry_tracing::layer()` so its thread-local is populated when
+/// sentry-tracing's `on_event` calls `capture_event`.
+pub fn init_tracing(miette_layer: MietteLayer, fmt_mode: FmtMode) -> miette::Result<()> {
+ let filter = EnvFilter::builder()
+ .with_env_var("QUIRE_LOG")
+ .from_env()
+ .into_diagnostic()?;
+
+ let layer = tracing_subscriber::fmt::layer().with_writer(std::io::stderr);
+ let fmt_layer = match fmt_mode {
+ FmtMode::Plain => layer.boxed(),
+ FmtMode::AutoJson => {
+ if std::io::stderr().is_terminal() {
+ layer.boxed()
+ } else {
+ layer.json().boxed()
+ }
+ }
+ };
+
+ tracing_subscriber::registry()
+ .with(miette_layer)
+ .with(sentry_tracing::layer())
+ .with(fmt_layer)
+ .with(filter)
+ .init();
+
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::*;
- use std::sync::{Arc, Mutex};
- use tracing_subscriber::layer::SubscriberExt;
+ use std::sync::Mutex;
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[error("outer message")]
diff --git a/quire-server/Cargo.toml b/quire-server/Cargo.toml
index 13f1a25..7ddf103 100644
--- a/quire-server/Cargo.toml
+++ b/quire-server/Cargo.toml
@@ -37,11 +37,9 @@ clap_complete = "*"
rusqlite = { version = "*", features = ["bundled"] }
rusqlite_migration = "*"
sentry = { workspace = true }
-sentry-tracing = { workspace = true }
serde_yaml_ng = "*"
shell-words = "*"
tokio = { workspace = true, features = ["full"] }
-tracing-subscriber = { workspace = true, features = ["json"] }
uuid = { version = "*", features = ["v7"] }
walkdir = "*"
diff --git a/quire-server/src/bin/quire/main.rs b/quire-server/src/bin/quire/main.rs
index 6a3aa7f..8a9cf1b 100644
--- a/quire-server/src/bin/quire/main.rs
+++ b/quire-server/src/bin/quire/main.rs
@@ -6,12 +6,8 @@ use clap_complete::Shell;
use miette::IntoDiagnostic;
use miette::Result;
use quire::Quire;
+use quire_core::telemetry::{self, FmtMode, MietteLayer};
use sentry::ClientInitGuard;
-use std::io::IsTerminal;
-use tracing_subscriber::EnvFilter;
-use tracing_subscriber::Layer;
-use tracing_subscriber::fmt;
-use tracing_subscriber::prelude::*;
const VERSION: &str = env!("QUIRE_VERSION");
@@ -133,43 +129,10 @@ fn init_sentry(quire: &Quire) -> Option<ClientInitGuard> {
}
};
- let guard = sentry::init((
+ Some(sentry::init((
dsn,
- sentry::ClientOptions {
- release: Some(VERSION.into()),
- before_send: Some(std::sync::Arc::new(quire_core::telemetry::before_send)),
- ..Default::default()
- },
- ));
-
- Some(guard)
-}
-
-/// Initialize tracing with a stderr fmt layer.
-///
-/// 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(miette_layer: quire_core::telemetry::MietteLayer) -> Result<()> {
- let filter = EnvFilter::builder()
- .with_env_var("QUIRE_LOG")
- .from_env()
- .into_diagnostic()?;
-
- let layer = fmt::layer().with_writer(std::io::stderr);
- let fmt_layer = if std::io::stderr().is_terminal() {
- layer.boxed()
- } else {
- layer.json().boxed()
- };
-
- tracing_subscriber::registry()
- .with(miette_layer)
- .with(sentry_tracing::layer())
- .with(fmt_layer)
- .with(filter)
- .init();
-
- Ok(())
+ telemetry::sentry_client_options(VERSION),
+ )))
}
#[tokio::main]
@@ -181,11 +144,11 @@ async fn main() -> Result<()> {
None => Quire::default(),
};
let _sentry = init_sentry(&quire);
- let miette_layer = quire_core::telemetry::MietteLayer::new()
+ let miette_layer = MietteLayer::new()
.with_type::<quire::Error>()
.with_type::<quire::ci::Error>()
.with_type::<quire_core::fennel::FennelError>();
- init_tracing(miette_layer)?;
+ telemetry::init_tracing(miette_layer, FmtMode::AutoJson)?;
if let Some(shell) = cli.completions {
clap_complete::generate(shell, &mut Cli::command(), "quire", &mut std::io::stdout());