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>
change soyktwpnruvvwslwtpqtpvuvvmmwwvnu
commit 81021d3929a836ca829c8e2f2f5532cff3cce700
author Alpha Chen <alpha@kejadlen.dev>
date
parent ozvpuvrl
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());