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
change rqztlmuzvnptyowtrpywovtkzyrkquzs
commit b9c1d89d5d0d1ee7ead62177661cf23d787f63b3
author Alpha Chen <alpha@kejadlen.dev>
date
parent owplswuu
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)?;