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
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]