Tag Sentry events with repo and run id
Both already live in the runs row, so the bootstrap endpoint
surfaces them without a migration.

Assisted-by: Claude Opus 4.7 via Claude Code
change rrtwtzwlrpvppoyxvqwlrwrmtpmyyxku
commit 3c1a801c8e80ebca5e8a9a902033277ea8db1c14
author Alpha Chen <alpha@kejadlen.dev>
date
parent 86ab6311
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index da23f1b..89faa0a 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -238,11 +238,19 @@ impl RunClient {
     ///
     /// One-shot: the server marks the bootstrap as fetched after the first
     /// successful call and returns 410 on any subsequent call.
-    fn fetch_bootstrap(&self) -> Result<(PathBuf, RunMeta, Option<String>)> {
+    fn fetch_bootstrap(&self) -> Result<(PathBuf, RunMeta, SentryContext)> {
         let bootstrap: Bootstrap =
             (|| -> reqwest::Result<_> { self.get("bootstrap")?.error_for_status()?.json() })()
                 .into_diagnostic()?;
-        Ok((bootstrap.git_dir, bootstrap.meta, bootstrap.sentry_trace_id))
+        Ok((
+            bootstrap.git_dir,
+            bootstrap.meta,
+            SentryContext {
+                trace_id: bootstrap.sentry_trace_id,
+                repo: Some(bootstrap.repo),
+                run_id: Some(bootstrap.run_id),
+            },
+        ))
     }
 
     /// Fetch a single secret by name from the server.
@@ -321,7 +329,7 @@ fn main() -> Result<()> {
             };
             let client = RunClient::new(session.clone());
 
-            let (git_dir, meta, sentry_trace_id) = if local {
+            let (git_dir, meta, sentry_ctx) = if local {
                 let Some(git_dir) = git_dir else {
                     bail!("--git-dir is required for local runs");
                 };
@@ -332,7 +340,7 @@ fn main() -> Result<()> {
                     r#ref: git_ref,
                     pushed_at: jiff::Timestamp::now(),
                 };
-                (git_dir, meta, None)
+                (git_dir, meta, SentryContext::default())
             } else {
                 client.fetch_bootstrap()?
             };
@@ -343,7 +351,7 @@ fn main() -> Result<()> {
                 .quire
                 .sentry_dsn
                 .as_deref()
-                .map(|dsn| init_sentry(dsn, sentry_trace_id.as_deref(), &meta));
+                .map(|dsn| init_sentry(dsn, &meta, &sentry_ctx));
 
             // No type registrations: quire-ci's user-level errors
             // (CompileError, JobError, FennelError) are no longer logged
@@ -360,18 +368,37 @@ fn main() -> Result<()> {
     }
 }
 
+/// Sentry-only run context from the bootstrap handoff. Every field is
+/// present together (orchestrator-dispatched with a DSN configured) or
+/// absent together (local run, which has no orchestrator). Kept apart
+/// from [`RunMeta`]: `meta` carries push facts the pipeline needs,
+/// these tag observability only.
+#[derive(Default)]
+struct SentryContext {
+    trace_id: Option<String>,
+    repo: Option<String>,
+    run_id: Option<String>,
+}
+
 /// Initialize Sentry. 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. When a trace id is also
-/// available (from the bootstrap handoff), attaches it so both sides'
-/// events group on the same trace.
-fn init_sentry(dsn: &str, trace_id: Option<&str>, meta: &RunMeta) -> sentry::ClientInitGuard {
+/// from quire-server's in the same project. `repo`, `run_id`, and the
+/// trace context come from the bootstrap handoff and are attached only
+/// when present (absent for local runs); the trace id links both
+/// sides' events onto the same trace.
+fn init_sentry(dsn: &str, meta: &RunMeta, ctx: &SentryContext) -> sentry::ClientInitGuard {
     let guard = sentry::init((dsn, telemetry::sentry_client_options(VERSION)));
     sentry::configure_scope(|scope| {
         scope.set_tag("service", "quire-ci");
         scope.set_tag("sha", &meta.sha);
         scope.set_tag("ref", &meta.r#ref);
-        if let Some(tid) = trace_id {
+        if let Some(repo) = &ctx.repo {
+            scope.set_tag("repo", repo);
+        }
+        if let Some(run_id) = &ctx.run_id {
+            scope.set_tag("run_id", run_id);
+        }
+        if let Some(tid) = ctx.trace_id.as_deref() {
             match tid.parse::<sentry::protocol::TraceId>() {
                 Ok(trace_id) => {
                     scope.set_context(
diff --git a/quire-core/src/ci/bootstrap.rs b/quire-core/src/ci/bootstrap.rs
index d581e0f..0d51ccb 100644
--- a/quire-core/src/ci/bootstrap.rs
+++ b/quire-core/src/ci/bootstrap.rs
@@ -22,6 +22,12 @@ use crate::ci::run::RunMeta;
 pub struct Bootstrap {
     pub meta: RunMeta,
     pub git_dir: PathBuf,
+    /// The repo this run is scoped to (matches the `runs.repo`
+    /// column). quire-ci tags Sentry events with it.
+    pub repo: String,
+    /// The server-assigned run id (UUIDv7, the `runs.id` PK).
+    /// quire-ci tags Sentry events with it.
+    pub run_id: String,
     /// Sentry trace id for the orchestrator's span, present only when
     /// the global config sets a DSN. Allows quire-ci to attach its
     /// events to the same trace. The DSN itself travels via the
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 2c0bcba..6d70d57 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -183,6 +183,7 @@ fn run_ref(
     let sentry_trace_id = ctx.sentry_dsn.as_ref().map(|_| trace_id.to_string());
     sentry::with_scope(
         |scope| {
+            scope.set_tag("repo", ctx.event_repo);
             scope.set_context(
                 "trace",
                 sentry::protocol::Context::Trace(Box::new(sentry::protocol::TraceContext {
@@ -236,6 +237,11 @@ fn run_ref_inner(
 
     let run = ctx.repo.runs(ctx.db_path).create(&meta, Some(session))?;
 
+    // The run id only exists post-create, after run_ref already opened
+    // the scope; mutate that scope so the tag also covers the "CI
+    // trigger failed" event run_ref emits if this fn returns Err.
+    sentry::configure_scope(|scope| scope.set_tag("run_id", run.id()));
+
     tracing::info!(
         run_id = %run.id(), // cov-excl-line
         sha = %push_ref.new_sha,
diff --git a/quire-server/src/quire/web/api.rs b/quire-server/src/quire/web/api.rs
index b1af911..9ef61b7 100644
--- a/quire-server/src/quire/web/api.rs
+++ b/quire-server/src/quire/web/api.rs
@@ -156,11 +156,12 @@ async fn get_bootstrap(
                 git_dir: Option<String>,
                 sentry_trace_id: Option<String>,
                 state: String,
+                repo: String,
             }
 
             let row: RunRow = db
                 .prepare(
-                    "SELECT sha, ref_name, pushed_at_ms, git_dir, sentry_trace_id, state
+                    "SELECT sha, ref_name, pushed_at_ms, git_dir, sentry_trace_id, state, repo
                      FROM runs WHERE id = ?1",
                 )?
                 .query_and_then(rusqlite::params![run_id], serde_rusqlite::from_row)?
@@ -189,6 +190,8 @@ async fn get_bootstrap(
             Ok(Bootstrap {
                 meta,
                 git_dir,
+                repo: row.repo,
+                run_id,
                 sentry_trace_id: row.sentry_trace_id,
             })
         })
@@ -320,7 +323,7 @@ mod tests {
     async fn bootstrap_returns_payload_on_first_fetch() {
         let env = TestEnv::new();
         let session = ApiSession::new(3000);
-        create_run_with_bootstrap(&env, &session, "/repos/test.git", None).await;
+        let run_id = create_run_with_bootstrap(&env, &session, "/repos/test.git", None).await;
 
         let resp = get(env.app(), "/run/bootstrap", Some(&session.run_token)).await;
         assert_eq!(resp.status(), StatusCode::OK);
@@ -329,6 +332,8 @@ mod tests {
         let body = resp.into_body().collect().await.unwrap().to_bytes();
         let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json body");
         assert_eq!(parsed["git_dir"], "/repos/test.git");
+        assert_eq!(parsed["repo"], "test.git");
+        assert_eq!(parsed["run_id"], run_id);
     }
 
     #[tokio::test]