Add Sentry to quire-ci
DSN passes through the dispatch file alongside meta and secrets —
quire-ci runs in the CI job container with no host global config
mount. A single-worker Tokio runtime hosts sentry's reqwest transport
without forcing main into async.

Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
change owplswuulwkxlskmmnnmqqvrrqznyzku
commit 6bfbb289dadbed451ddaaa48d85c154eac2eeeaf
author Alpha Chen <alpha@kejadlen.dev>
date
parent luvwoonr
diff --git a/Cargo.lock b/Cargo.lock
index d69f4b1..fdfac15 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2133,9 +2133,14 @@ dependencies = [
  "miette",
  "mlua",
  "quire-core",
+ "sentry",
+ "sentry-tracing",
  "serde",
  "serde_json",
  "tempfile",
+ "tokio",
+ "tracing",
+ "tracing-subscriber",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index ba1c458..c1877af 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,8 +10,12 @@ miette = "*"
 mlua = { version = "*", features = ["lua54", "serde", "vendored", "error-send"] }
 petgraph = "*"
 regex = "*"
+sentry = { version = "*", features = ["backtrace", "contexts", "debug-images", "panic", "release-health", "reqwest", "rustls", "tokio"], default-features = false }
+sentry-tracing = "*"
 serde = { version = "*", features = ["derive"] }
 serde_json = "*"
 tempfile = "*"
 thiserror = "*"
+tokio = "*"
 tracing = "*"
+tracing-subscriber = { version = "*", features = ["env-filter"] }
diff --git a/docs/config.md b/docs/config.md
index 1528319..75639cf 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -11,7 +11,7 @@ Operator-created. Re-read on every call (no caching today).
 
 | Key            | Type           | Required | Purpose                                                  |
 |----------------|----------------|----------|----------------------------------------------------------|
-| `:sentry :dsn` | `SecretString` | no       | Sentry DSN for error reporting. Omit to disable.         |
+| `:sentry :dsn` | `SecretString` | no       | Sentry DSN for error reporting from both `quire` and `quire-ci`. Omit to disable. |
 | `:secrets`     | table          | no       | Named secrets exposed to `ci.fnl` jobs as `(secret :name)`. |
 
 Minimal (no Sentry, no secrets):
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index b22b53a..530cd3c 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -10,6 +10,11 @@ jiff = { workspace = true }
 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 }
+tokio = { workspace = true, features = ["rt-multi-thread"] }
+tracing = { workspace = true }
+tracing-subscriber = { workspace = true }
diff --git a/quire-ci/build.rs b/quire-ci/build.rs
new file mode 100644
index 0000000..0815b86
--- /dev/null
+++ b/quire-ci/build.rs
@@ -0,0 +1,31 @@
+use std::process::Command;
+
+fn main() {
+    println!("cargo:rerun-if-env-changed=QUIRE_VERSION");
+    let version = std::env::var("QUIRE_VERSION").unwrap_or_else(|_| {
+        let date = cmd("date", &["-u", "+%Y.%m.%d"]);
+        let change = cmd(
+            "jj",
+            &[
+                "log",
+                "--revisions",
+                "@",
+                "--no-graph",
+                "--template",
+                "change_id.short()",
+            ],
+        );
+        format!("{date}+{change}-dev")
+    });
+    println!("cargo:rustc-env=QUIRE_VERSION={version}");
+}
+
+fn cmd(program: &str, args: &[&str]) -> String {
+    Command::new(program)
+        .args(args)
+        .output()
+        .ok()
+        .and_then(|o| String::from_utf8(o.stdout).ok())
+        .map(|s| s.trim().to_string())
+        .unwrap_or_else(|| "unknown".into())
+}
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 2b3692b..1eea441 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -12,9 +12,12 @@ use quire_core::ci::event::{Event, EventKind, JobOutcome};
 use quire_core::ci::pipeline::{self, Pipeline, RunFn};
 use quire_core::ci::run::RunMeta;
 use quire_core::ci::runtime::{Runtime, RuntimeError, RuntimeEvent, RuntimeHandle};
+use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
 
 use crate::sink::{EventSink, JsonlSink, NullSink};
 
+const VERSION: &str = env!("QUIRE_VERSION");
+
 /// Run a quire CI pipeline locally.
 #[derive(Parser)]
 #[command(version, propagate_version = true)]
@@ -167,19 +170,77 @@ fn main() -> miette::Result<()> {
                     (path, Some(DumpLogsOnDrop { dir }))
                 }
             };
-            let (git_dir, meta, secrets) = match dispatch {
+            let (git_dir, meta, secrets, sentry_dsn) = match dispatch {
                 Some(path) => load_dispatch(&path)?,
                 None => (
                     cli.workspace.join(".git"),
                     placeholder_meta(),
                     HashMap::new(),
+                    None,
                 ),
             };
+
+            // Sentry's reqwest transport spawns Tokio tasks for HTTP
+            // sends, so the client must be constructed and dropped from
+            // within a runtime context. A single worker thread is
+            // enough — the main thread does the synchronous pipeline
+            // work and only crosses into Tokio when sentry flushes.
+            let rt = tokio::runtime::Builder::new_multi_thread()
+                .worker_threads(1)
+                .enable_all()
+                .build()
+                .into_diagnostic()?;
+            let _enter = rt.enter();
+
+            // Drop order: `_sentry` flushes first (still inside the
+            // runtime), then `_enter`, then `rt`.
+            let _sentry = init_sentry(sentry_dsn.as_deref(), &meta);
+            init_tracing()?;
+
             run_pipeline(cli.workspace, sink, log_dir, git_dir, meta, secrets)
         }
     }
 }
 
+/// Initialize Sentry when the orchestrator passed a DSN. Tags the
+/// scope with `service=quire-ci` plus the run's sha and ref so events
+/// from this binary are distinguishable from quire-server's in the
+/// same project. Returns the guard the caller must keep alive.
+fn init_sentry(dsn: Option<&str>, meta: &RunMeta) -> Option<sentry::ClientInitGuard> {
+    let dsn = dsn?;
+    let guard = sentry::init((
+        dsn,
+        sentry::ClientOptions {
+            release: Some(VERSION.into()),
+            ..Default::default()
+        },
+    ));
+    sentry::configure_scope(|scope| {
+        scope.set_tag("service", "quire-ci");
+        scope.set_tag("sha", &meta.sha);
+        scope.set_tag("ref", &meta.r#ref);
+    });
+    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::Result<()> {
+    let filter = EnvFilter::builder()
+        .with_env_var("QUIRE_LOG")
+        .from_env()
+        .into_diagnostic()?;
+
+    tracing_subscriber::registry()
+        .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)?;
 
@@ -217,12 +278,16 @@ fn placeholder_meta() -> RunMeta {
 
 /// Read and parse the dispatch file the orchestrator wrote before
 /// spawning. Wraps revealed secret values back into `SecretString`.
+/// The Sentry DSN, if any, comes through as a plain string — the
+/// 0600 dispatch file is the line of defense.
+#[allow(clippy::type_complexity)]
 fn load_dispatch(
     path: &std::path::Path,
 ) -> miette::Result<(
     PathBuf,
     RunMeta,
     HashMap<String, quire_core::secret::SecretString>,
+    Option<String>,
 )> {
     use quire_core::ci::dispatch::Dispatch;
     use quire_core::secret::SecretString;
@@ -234,7 +299,12 @@ fn load_dispatch(
         .into_iter()
         .map(|(name, value)| (name, SecretString::from(value)))
         .collect();
-    Ok((dispatch.git_dir, dispatch.meta, secrets))
+    Ok((
+        dispatch.git_dir,
+        dispatch.meta,
+        secrets,
+        dispatch.sentry_dsn,
+    ))
 }
 
 fn run_pipeline(
diff --git a/quire-core/src/ci/dispatch.rs b/quire-core/src/ci/dispatch.rs
index 6a230d8..e662570 100644
--- a/quire-core/src/ci/dispatch.rs
+++ b/quire-core/src/ci/dispatch.rs
@@ -36,4 +36,9 @@ pub struct Dispatch {
     pub meta: RunMeta,
     pub git_dir: PathBuf,
     pub secrets: HashMap<String, String>,
+    /// Sentry DSN, when the orchestrator's global config sets one.
+    /// Plaintext, like the secrets above — the 0600 mode on the
+    /// dispatch file is the line of defense.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub sentry_dsn: Option<String>,
 }
diff --git a/quire-server/Cargo.toml b/quire-server/Cargo.toml
index 49709e6..13f1a25 100644
--- a/quire-server/Cargo.toml
+++ b/quire-server/Cargo.toml
@@ -36,12 +36,12 @@ clap = { workspace = true }
 clap_complete = "*"
 rusqlite = { version = "*", features = ["bundled"] }
 rusqlite_migration = "*"
-sentry = { version = "*", features = ["backtrace", "contexts", "debug-images", "panic", "release-health", "reqwest", "rustls", "tokio"], default-features = false }
-sentry-tracing = "*"
+sentry = { workspace = true }
+sentry-tracing = { workspace = true }
 serde_yaml_ng = "*"
 shell-words = "*"
-tokio = { version = "*", features = ["full"] }
-tracing-subscriber = { version = "*", features = ["env-filter", "json"] }
+tokio = { workspace = true, features = ["full"] }
+tracing-subscriber = { workspace = true, features = ["json"] }
 uuid = { version = "*", features = ["v7"] }
 walkdir = "*"
 
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 588d081..0d95b1d 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -128,6 +128,17 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
         }
     };
 
+    let sentry_dsn = config.sentry.as_ref().and_then(|s| match s.dsn.reveal() {
+        Ok(dsn) => Some(dsn.to_string()),
+        Err(e) => {
+            tracing::warn!(
+                error = %e,
+                "failed to resolve Sentry DSN, quire-ci runs will skip Sentry",
+            );
+            None
+        }
+    });
+
     let db_path = quire.db_path();
     for push_ref in event.updated_refs() {
         if let Err(e) = trigger_ref(
@@ -137,6 +148,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
             push_ref,
             &config.secrets,
             config.executor,
+            sentry_dsn.as_deref(),
         ) {
             tracing::error!(
                 repo = %event.repo,
@@ -156,6 +168,7 @@ fn trigger_ref(
     push_ref: &PushRef,
     secrets: &HashMap<String, quire_core::secret::SecretString>,
     executor: Executor,
+    sentry_dsn: Option<&str>,
 ) -> error::Result<()> {
     let ci = repo.ci();
 
@@ -197,7 +210,7 @@ fn trigger_ref(
             // The orchestrator already validated `pipeline` to fail-fast on
             // bad ci.fnl; `quire-ci` recompiles inside its own process.
             drop(pipeline);
-            run.execute_via_quire_ci(&repo.path(), &workspace, &meta, secrets)?;
+            run.execute_via_quire_ci(&repo.path(), &workspace, &meta, secrets, sentry_dsn)?;
         }
     }
     Ok(())
@@ -377,6 +390,7 @@ mod tests {
             &push_ref,
             &HashMap::new(),
             Executor::Host,
+            None,
         )
         .expect("trigger_ref should succeed");
 
@@ -411,6 +425,7 @@ mod tests {
             &push_ref,
             &HashMap::new(),
             Executor::Host,
+            None,
         )
         .expect("should succeed without ci.fnl");
     }
@@ -435,6 +450,7 @@ mod tests {
             &push_ref,
             &HashMap::new(),
             Executor::Host,
+            None,
         );
         assert!(result.is_err(), "invalid pipeline should fail");
     }
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 658dfb9..f6b1b70 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -332,6 +332,7 @@ impl Run {
         workspace: &Path,
         meta: &RunMeta,
         secrets: &HashMap<String, SecretString>,
+        sentry_dsn: Option<&str>,
     ) -> Result<()> {
         self.transition(RunState::Active)?;
 
@@ -344,7 +345,7 @@ impl Run {
         let log = fs_err::File::create(&log_path)?.into_parts().0;
         let log_clone = log.try_clone()?;
 
-        write_dispatch(&dispatch_path, git_dir, meta, secrets)?;
+        write_dispatch(&dispatch_path, git_dir, meta, secrets, sentry_dsn)?;
 
         tracing::info!(
             run_id = %self.id,
@@ -619,6 +620,7 @@ fn write_dispatch(
     git_dir: &Path,
     meta: &RunMeta,
     secrets: &HashMap<String, SecretString>,
+    sentry_dsn: Option<&str>,
 ) -> Result<()> {
     use quire_core::ci::dispatch::Dispatch;
 
@@ -633,6 +635,7 @@ fn write_dispatch(
         meta: meta.clone(),
         git_dir: git_dir.to_path_buf(),
         secrets: revealed,
+        sentry_dsn: sentry_dsn.map(String::from),
     };
     let json = serde_json::to_vec_pretty(&dispatch).map_err(std::io::Error::other)?;
 
@@ -836,8 +839,14 @@ mod tests {
         let dispatch_path = dir.path().join("dispatch.json");
         let git_dir = dir.path().join("repos").join("test.git");
 
-        write_dispatch(&dispatch_path, &git_dir, &test_meta(), &HashMap::new())
-            .expect("write_dispatch");
+        write_dispatch(
+            &dispatch_path,
+            &git_dir,
+            &test_meta(),
+            &HashMap::new(),
+            None,
+        )
+        .expect("write_dispatch");
 
         let bytes = fs_err::read(&dispatch_path).expect("read dispatch");
         let dispatch: Dispatch = serde_json::from_slice(&bytes).expect("parse dispatch");