Link quire-ci and quire-server Sentry events via trace_id
Both sides set the same trace_id on their scope, so a quire-ci panic
and the orchestrator-side "CI trigger failed" event group on one
trace in Sentry. DSN and trace_id travel together as a SentryHandoff
— neither is useful without the other.
Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 1eea441..bfb64eb 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -170,7 +170,7 @@ fn main() -> miette::Result<()> {
(path, Some(DumpLogsOnDrop { dir }))
}
};
- let (git_dir, meta, secrets, sentry_dsn) = match dispatch {
+ let (git_dir, meta, secrets, sentry_handoff) = match dispatch {
Some(path) => load_dispatch(&path)?,
None => (
cli.workspace.join(".git"),
@@ -194,7 +194,7 @@ fn main() -> miette::Result<()> {
// Drop order: `_sentry` flushes first (still inside the
// runtime), then `_enter`, then `rt`.
- let _sentry = init_sentry(sentry_dsn.as_deref(), &meta);
+ let _sentry = init_sentry(sentry_handoff.as_ref(), &meta);
init_tracing()?;
run_pipeline(cli.workspace, sink, log_dir, git_dir, meta, secrets)
@@ -202,14 +202,20 @@ fn main() -> miette::Result<()> {
}
}
-/// 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?;
+/// Initialize Sentry when the orchestrator passed a handoff. 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, and attaches the orchestrator's trace id so
+/// the two sides' events group on the same trace. A malformed
+/// trace_id (shouldn't happen — the orchestrator emits the canonical
+/// hex form) is logged and skipped rather than aborting Sentry init.
+fn init_sentry(
+ handoff: Option<&quire_core::ci::dispatch::SentryHandoff>,
+ meta: &RunMeta,
+) -> Option<sentry::ClientInitGuard> {
+ let handoff = handoff?;
let guard = sentry::init((
- dsn,
+ handoff.dsn.as_str(),
sentry::ClientOptions {
release: Some(VERSION.into()),
..Default::default()
@@ -219,6 +225,26 @@ fn init_sentry(dsn: Option<&str>, meta: &RunMeta) -> Option<sentry::ClientInitGu
scope.set_tag("service", "quire-ci");
scope.set_tag("sha", &meta.sha);
scope.set_tag("ref", &meta.r#ref);
+ match handoff.trace_id.parse::<sentry::protocol::TraceId>() {
+ Ok(trace_id) => {
+ scope.set_context(
+ "trace",
+ sentry::protocol::Context::Trace(Box::new(sentry::protocol::TraceContext {
+ trace_id,
+ span_id: sentry::protocol::SpanId::default(),
+ op: Some("quire.ci.run".into()),
+ ..Default::default()
+ })),
+ );
+ }
+ Err(e) => {
+ tracing::warn!(
+ trace_id = %handoff.trace_id,
+ error = %e,
+ "malformed trace_id in dispatch; quire-ci events won't link to orchestrator",
+ );
+ }
+ }
});
Some(guard)
}
@@ -278,8 +304,9 @@ 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.
+/// The Sentry handoff, when present, carries the DSN and the
+/// orchestrator's trace id — the 0600 dispatch file is the line of
+/// defense for both.
#[allow(clippy::type_complexity)]
fn load_dispatch(
path: &std::path::Path,
@@ -287,7 +314,7 @@ fn load_dispatch(
PathBuf,
RunMeta,
HashMap<String, quire_core::secret::SecretString>,
- Option<String>,
+ Option<quire_core::ci::dispatch::SentryHandoff>,
)> {
use quire_core::ci::dispatch::Dispatch;
use quire_core::secret::SecretString;
@@ -299,12 +326,7 @@ fn load_dispatch(
.into_iter()
.map(|(name, value)| (name, SecretString::from(value)))
.collect();
- Ok((
- dispatch.git_dir,
- dispatch.meta,
- secrets,
- dispatch.sentry_dsn,
- ))
+ Ok((dispatch.git_dir, dispatch.meta, secrets, dispatch.sentry))
}
fn run_pipeline(
diff --git a/quire-core/src/ci/dispatch.rs b/quire-core/src/ci/dispatch.rs
index e662570..d0f0353 100644
--- a/quire-core/src/ci/dispatch.rs
+++ b/quire-core/src/ci/dispatch.rs
@@ -36,9 +36,21 @@ 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.
+ /// Sentry handoff, present only when the orchestrator's global
+ /// config sets a DSN. Carries the matching trace id so both
+ /// sides' events land on the same trace.
#[serde(default, skip_serializing_if = "Option::is_none")]
- pub sentry_dsn: Option<String>,
+ pub sentry: Option<SentryHandoff>,
+}
+
+/// What quire-ci needs to mirror the orchestrator's Sentry context.
+///
+/// The DSN is plaintext like the secrets — the 0600 mode on the
+/// dispatch file is the line of defense. `trace_id` is the hex form
+/// of [`sentry::protocol::TraceId`]; kept as a string here so
+/// `quire-core` doesn't grow a `sentry` dep.
+#[derive(Debug, Serialize, Deserialize)]
+pub struct SentryHandoff {
+ pub dsn: String,
+ pub trace_id: String,
}
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 0d95b1d..98145ab 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -141,22 +141,54 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
let db_path = quire.db_path();
for push_ref in event.updated_refs() {
- if let Err(e) = trigger_ref(
- &repo,
- &db_path,
- event.pushed_at,
- push_ref,
- &config.secrets,
- config.executor,
- sentry_dsn.as_deref(),
- ) {
- tracing::error!(
- repo = %event.repo,
- sha = %push_ref.new_sha, // cov-excl-line
- error = %display_chain(&e),
- "CI trigger failed"
- );
- }
+ // One trace per push_ref. The trace context is set on the
+ // orchestrator's scope for the duration of this iteration and
+ // propagated to quire-ci through the dispatch file, so a
+ // quire-ci panic and the orchestrator-side "CI trigger
+ // failed" event end up on the same trace in Sentry. DSN and
+ // trace_id travel together — no DSN, no handoff, no trace
+ // tagging is observable.
+ let trace_id = sentry::protocol::TraceId::default();
+ let span_id = sentry::protocol::SpanId::default();
+ let sentry_handoff =
+ sentry_dsn
+ .as_ref()
+ .map(|dsn| quire_core::ci::dispatch::SentryHandoff {
+ dsn: dsn.clone(),
+ trace_id: trace_id.to_string(),
+ });
+
+ sentry::with_scope(
+ |scope| {
+ scope.set_context(
+ "trace",
+ sentry::protocol::Context::Trace(Box::new(sentry::protocol::TraceContext {
+ trace_id,
+ span_id,
+ op: Some("quire.ci.run".into()),
+ ..Default::default()
+ })),
+ );
+ },
+ || {
+ if let Err(e) = trigger_ref(
+ &repo,
+ &db_path,
+ event.pushed_at,
+ push_ref,
+ &config.secrets,
+ config.executor,
+ sentry_handoff.as_ref(),
+ ) {
+ tracing::error!(
+ repo = %event.repo,
+ sha = %push_ref.new_sha, // cov-excl-line
+ error = %display_chain(&e),
+ "CI trigger failed"
+ );
+ }
+ },
+ );
}
}
@@ -168,7 +200,7 @@ fn trigger_ref(
push_ref: &PushRef,
secrets: &HashMap<String, quire_core::secret::SecretString>,
executor: Executor,
- sentry_dsn: Option<&str>,
+ sentry: Option<&quire_core::ci::dispatch::SentryHandoff>,
) -> error::Result<()> {
let ci = repo.ci();
@@ -210,7 +242,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, sentry_dsn)?;
+ run.execute_via_quire_ci(&repo.path(), &workspace, &meta, secrets, sentry)?;
}
}
Ok(())
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index f6b1b70..3c99064 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -332,7 +332,7 @@ impl Run {
workspace: &Path,
meta: &RunMeta,
secrets: &HashMap<String, SecretString>,
- sentry_dsn: Option<&str>,
+ sentry: Option<&quire_core::ci::dispatch::SentryHandoff>,
) -> Result<()> {
self.transition(RunState::Active)?;
@@ -345,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, sentry_dsn)?;
+ write_dispatch(&dispatch_path, git_dir, meta, secrets, sentry)?;
tracing::info!(
run_id = %self.id,
@@ -620,9 +620,9 @@ fn write_dispatch(
git_dir: &Path,
meta: &RunMeta,
secrets: &HashMap<String, SecretString>,
- sentry_dsn: Option<&str>,
+ sentry: Option<&quire_core::ci::dispatch::SentryHandoff>,
) -> Result<()> {
- use quire_core::ci::dispatch::Dispatch;
+ use quire_core::ci::dispatch::{Dispatch, SentryHandoff};
let mut revealed: HashMap<String, String> = HashMap::with_capacity(secrets.len());
for (name, value) in secrets {
@@ -635,7 +635,10 @@ fn write_dispatch(
meta: meta.clone(),
git_dir: git_dir.to_path_buf(),
secrets: revealed,
- sentry_dsn: sentry_dsn.map(String::from),
+ sentry: sentry.map(|s| SentryHandoff {
+ dsn: s.dsn.clone(),
+ trace_id: s.trace_id.clone(),
+ }),
};
let json = serde_json::to_vec_pretty(&dispatch).map_err(std::io::Error::other)?;