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
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");