Replace manual Sentry trace IDs with OpenTelemetry span propagation
Add opentelemetry, opentelemetry_sdk, sentry-opentelemetry, and
tracing-opentelemetry to the dependency stack. The tracing subscriber
now includes an OTEL layer wired to SentrySpanProcessor, so tracing
spans become Sentry transactions automatically.

Cross-process trace continuity: quire-server creates an info_span for
each CI run and extracts the W3C traceparent header via
current_traceparent(), which travels to quire-ci through the bootstrap
API (new `traceparent` column, migration 0006). quire-ci calls
attach_traceparent() before entering its own root span, making it a
child of the orchestrator's span in Sentry's trace view.

Removes the manual sentry::protocol::TraceId/SpanId generation and the
hand-rolled TraceContext scope setup in both binaries.

https://claude.ai/code/session_01Tbgz29e8A9KS4Bh94skkFX
change
commit 41fb9ab49536e24c7ac767bdb03da38c80bc6ebf
author Claude <noreply@anthropic.com>
date
parent rrtwtzwl
diff --git a/Cargo.lock b/Cargo.lock
index affd8d3..89a8257 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1152,12 +1152,34 @@ version = "0.3.32"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
 
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
 [[package]]
 name = "futures-io"
 version = "0.3.32"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
 
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "futures-sink"
 version = "0.3.32"
@@ -1178,6 +1200,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
 dependencies = [
  "futures-core",
  "futures-io",
+ "futures-macro",
  "futures-sink",
  "futures-task",
  "memchr",
@@ -2251,6 +2274,38 @@ version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
 
+[[package]]
+name = "opentelemetry"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e87237e2775f74896f9ad219d26a2081751187eb7c9f5c58dde20a23b95d16c"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "js-sys",
+ "pin-project-lite",
+ "thiserror",
+ "tracing",
+]
+
+[[package]]
+name = "opentelemetry_sdk"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "afdefb21d1d47394abc1ba6c57363ab141be19e27cc70d0e422b7f303e4d290b"
+dependencies = [
+ "futures-channel",
+ "futures-executor",
+ "futures-util",
+ "glob",
+ "opentelemetry",
+ "percent-encoding",
+ "rand",
+ "serde_json",
+ "thiserror",
+ "tracing",
+]
+
 [[package]]
 name = "ordered-float"
 version = "2.10.1"
@@ -2519,16 +2574,20 @@ dependencies = [
  "jiff",
  "miette",
  "mlua",
+ "opentelemetry",
+ "opentelemetry_sdk",
  "petgraph",
  "rand",
  "regex",
  "sentry",
+ "sentry-opentelemetry",
  "sentry-tracing",
  "serde",
  "serde_json",
  "tempfile",
  "thiserror",
  "tracing",
+ "tracing-opentelemetry",
  "tracing-subscriber",
 ]
 
@@ -2990,9 +3049,9 @@ dependencies = [
 
 [[package]]
 name = "sentry-core"
-version = "0.48.0"
+version = "0.48.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "56de6f8c10ed1b74543b9654b99d3d9a2d876bd5996f3ecd60afdc30ef40ad0e"
+checksum = "545dc562b6758d646ac19e1407f4ebc26d452111386743e03323464bc48bb2e0"
 dependencies = [
  "rand",
  "sentry-types",
@@ -3011,6 +3070,17 @@ dependencies = [
  "sentry-core",
 ]
 
+[[package]]
+name = "sentry-opentelemetry"
+version = "0.48.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df2d807edab875bc9d133de3106a295796b6c9692a149799fb5157108f5636d5"
+dependencies = [
+ "opentelemetry",
+ "opentelemetry_sdk",
+ "sentry-core",
+]
+
 [[package]]
 name = "sentry-panic"
 version = "0.48.0"
@@ -3036,9 +3106,9 @@ dependencies = [
 
 [[package]]
 name = "sentry-types"
-version = "0.48.0"
+version = "0.48.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "00c69667ff14f47aad798e6be2ad8409e350b3ab0b8d72b338b462ed35f7bcc4"
+checksum = "041359745a44dd2e14fe21b7510fe7ca8b5beffce6636a0b52e5bc7d5f736887"
 dependencies = [
  "debugid",
  "hex",
@@ -3612,6 +3682,24 @@ dependencies = [
  "tracing-core",
 ]
 
+[[package]]
+name = "tracing-opentelemetry"
+version = "0.30.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd8e764bd6f5813fd8bebc3117875190c5b0415be8f7f8059bffb6ecd979c444"
+dependencies = [
+ "js-sys",
+ "once_cell",
+ "opentelemetry",
+ "opentelemetry_sdk",
+ "smallvec 1.15.1",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+ "tracing-subscriber",
+ "web-time",
+]
+
 [[package]]
 name = "tracing-serde"
 version = "0.2.0"
diff --git a/Cargo.toml b/Cargo.toml
index 0713734..bf45c81 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -13,6 +13,10 @@ petgraph = "*"
 regex = "*"
 sentry = { version = "*", features = ["backtrace", "contexts", "debug-images", "panic", "release-health", "reqwest", "rustls", "tokio"], default-features = false }
 sentry-tracing = "*"
+opentelemetry = "0.29.1"
+opentelemetry_sdk = "0.29.0"
+sentry-opentelemetry = "0.48.2"
+tracing-opentelemetry = "0.30.0"
 serde = { version = "*", features = ["derive"] }
 serde_json = "*"
 tempfile = "*"
diff --git a/quire-ci/src/main.rs b/quire-ci/src/main.rs
index 89faa0a..21b4c51 100644
--- a/quire-ci/src/main.rs
+++ b/quire-ci/src/main.rs
@@ -246,7 +246,7 @@ impl RunClient {
             bootstrap.git_dir,
             bootstrap.meta,
             SentryContext {
-                trace_id: bootstrap.sentry_trace_id,
+                traceparent: bootstrap.traceparent,
                 repo: Some(bootstrap.repo),
                 run_id: Some(bootstrap.run_id),
             },
@@ -359,7 +359,16 @@ fn main() -> Result<()> {
             // for them. The layer stays installed in case future ops
             // errors want to register types.
             let miette_layer = MietteLayer::new();
-            telemetry::init_tracing(miette_layer, FmtMode::Plain)?;
+            // _tracing_guard must be declared AFTER _sentry so it drops
+            // BEFORE _sentry — OTEL provider flushes spans to Sentry SDK
+            // before the Sentry client flushes to the server.
+            let _tracing_guard = telemetry::init_tracing(miette_layer, FmtMode::Plain)?;
+
+            // Attach the remote trace context so quire-ci spans appear
+            // as children of the orchestrator's span in Sentry.
+            let _cx_guard = sentry_ctx.traceparent.as_deref()
+                .map(|tp| telemetry::attach_traceparent(tp));
+            let _run_span = tracing::info_span!("quire.ci.run", sha = %meta.sha, r#ref = %meta.r#ref).entered();
 
             let registry = SecretRegistry::new(move |name| client.fetch_secret(name));
 
@@ -375,7 +384,7 @@ fn main() -> Result<()> {
 /// these tag observability only.
 #[derive(Default)]
 struct SentryContext {
-    trace_id: Option<String>,
+    traceparent: Option<String>,
     repo: Option<String>,
     run_id: Option<String>,
 }
@@ -398,30 +407,6 @@ fn init_sentry(dsn: &str, meta: &RunMeta, ctx: &SentryContext) -> sentry::Client
         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(
-                        "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 = %tid,
-                        error = %e,
-                        "malformed trace_id in bootstrap; quire-ci events won't link to orchestrator",
-                    );
-                }
-            }
-        }
     });
     guard
 }
diff --git a/quire-core/Cargo.toml b/quire-core/Cargo.toml
index f48944a..c1774c7 100644
--- a/quire-core/Cargo.toml
+++ b/quire-core/Cargo.toml
@@ -12,8 +12,12 @@ mlua = { workspace = true }
 petgraph = { workspace = true }
 rand = "*"
 regex = { workspace = true }
+opentelemetry = { workspace = true }
+opentelemetry_sdk = { workspace = true }
 sentry = { workspace = true }
+sentry-opentelemetry = { workspace = true }
 sentry-tracing = { workspace = true }
+tracing-opentelemetry = { workspace = true }
 serde = { workspace = true }
 serde_json = { workspace = true }
 thiserror = { workspace = true }
diff --git a/quire-core/src/ci/bootstrap.rs b/quire-core/src/ci/bootstrap.rs
index 0d51ccb..56bbc81 100644
--- a/quire-core/src/ci/bootstrap.rs
+++ b/quire-core/src/ci/bootstrap.rs
@@ -28,10 +28,10 @@ pub struct Bootstrap {
     /// 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
+    /// W3C traceparent header value 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
+    /// events to the same trace via OTEL context propagation. The DSN itself travels via the
     /// `QUIRE__SENTRY_DSN` environment variable.
     #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub sentry_trace_id: Option<String>,
+    pub traceparent: Option<String>,
 }
diff --git a/quire-core/src/telemetry.rs b/quire-core/src/telemetry.rs
index 0068f66..7113cf4 100644
--- a/quire-core/src/telemetry.rs
+++ b/quire-core/src/telemetry.rs
@@ -187,14 +187,51 @@ pub fn sentry_client_options(release: &'static str) -> sentry::ClientOptions {
     }
 }
 
+/// Returned by `init_tracing`; shuts down the OTEL TracerProvider on drop.
+pub struct TracingGuard {
+    provider: opentelemetry_sdk::trace::SdkTracerProvider,
+}
+
+impl Drop for TracingGuard {
+    fn drop(&mut self) {
+        let _ = self.provider.shutdown();
+    }
+}
+
+/// Opaque guard that restores the previous OTEL context on drop.
+#[allow(dead_code)]
+pub struct TraceparentGuard(opentelemetry::ContextGuard);
+
+/// Extract the W3C traceparent for the currently active tracing span.
+/// Returns None when no OTEL span is active (e.g. no DSN, not yet entered a span).
+pub fn current_traceparent() -> Option<String> {
+    use opentelemetry::propagation::TextMapPropagator;
+    let propagator = opentelemetry_sdk::propagation::TraceContextPropagator::new();
+    let cx = opentelemetry::Context::current();
+    let mut carrier = std::collections::HashMap::new();
+    propagator.inject_context(&cx, &mut carrier);
+    carrier.remove("traceparent")
+}
+
+/// Inject a W3C traceparent into the current thread's OTEL context.
+/// The returned guard restores the previous context on drop.
+pub fn attach_traceparent(traceparent: &str) -> TraceparentGuard {
+    use opentelemetry::propagation::TextMapPropagator;
+    let propagator = opentelemetry_sdk::propagation::TraceContextPropagator::new();
+    let mut carrier = std::collections::HashMap::new();
+    carrier.insert("traceparent".to_string(), traceparent.to_string());
+    let cx = propagator.extract(&carrier);
+    TraceparentGuard(cx.attach())
+}
+
 /// Initialize the global tracing subscriber with `QUIRE_LOG`-driven filtering,
-/// a stderr fmt layer per `fmt_mode`, the `sentry-tracing` bridge, and the
-/// supplied [`MietteLayer`].
+/// a stderr fmt layer per `fmt_mode`, the `sentry-tracing` bridge, the
+/// supplied [`MietteLayer`], and an OTEL tracing layer wired to Sentry.
 ///
 /// Layer ordering is baked in: `miette_layer` registers before
 /// `sentry_tracing::layer()` so its thread-local is populated when
 /// sentry-tracing's `on_event` calls `capture_event`.
-pub fn init_tracing(miette_layer: MietteLayer, fmt_mode: FmtMode) -> miette::Result<()> {
+pub fn init_tracing(miette_layer: MietteLayer, fmt_mode: FmtMode) -> miette::Result<TracingGuard> {
     let filter = EnvFilter::builder()
         .with_env_var("QUIRE_LOG")
         .from_env()
@@ -212,14 +249,22 @@ pub fn init_tracing(miette_layer: MietteLayer, fmt_mode: FmtMode) -> miette::Res
         }
     };
 
+    use opentelemetry::trace::TracerProvider as _;
+    let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
+        .with_span_processor(sentry_opentelemetry::SentrySpanProcessor::new())
+        .build();
+    opentelemetry::global::set_tracer_provider(provider.clone());
+    let tracer = provider.tracer("quire");
+
     tracing_subscriber::registry()
         .with(miette_layer)
         .with(sentry_tracing::layer())
+        .with(tracing_opentelemetry::layer().with_tracer(tracer))
         .with(fmt_layer)
         .with(filter)
         .init();
 
-    Ok(())
+    Ok(TracingGuard { provider })
 }
 
 #[cfg(test)]
diff --git a/quire-server/migrations/0006_traceparent.sql b/quire-server/migrations/0006_traceparent.sql
new file mode 100644
index 0000000..2989404
--- /dev/null
+++ b/quire-server/migrations/0006_traceparent.sql
@@ -0,0 +1 @@
+ALTER TABLE runs ADD COLUMN traceparent TEXT;
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 6d70d57..cde1875 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -11,6 +11,7 @@ pub use quire_core::ci::pipeline::{
 pub use quire_core::ci::run::ApiSession;
 pub use quire_core::ci::run::RunMeta;
 pub use quire_core::ci::{pipeline, registration, runtime};
+use quire_core::telemetry;
 pub use run::{Executor, Run, RunState, Runs, materialize_workspace, reconcile_orphans};
 
 /// A resolved commit reference.
@@ -158,16 +159,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
     };
 
     for push_ref in event.updated_refs() {
-        // 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();
-        run_ref(&ctx, event.pushed_at, push_ref, trace_id, span_id);
+        run_ref(&ctx, event.pushed_at, push_ref);
     }
 }
 
@@ -176,23 +168,19 @@ fn run_ref(
     ctx: &TriggerContext<'_>,
     pushed_at: jiff::Timestamp,
     push_ref: &PushRef,
-    trace_id: sentry::protocol::TraceId,
-    span_id: sentry::protocol::SpanId,
 ) {
     let session = ApiSession::new(ctx.port);
-    let sentry_trace_id = ctx.sentry_dsn.as_ref().map(|_| trace_id.to_string());
+
+    let span = tracing::info_span!("quire.ci.run", repo = %ctx.event_repo);
+    let _guard = span.enter();
+
+    let traceparent = ctx.sentry_dsn.as_ref()
+        .and_then(|_| telemetry::current_traceparent());
+
     sentry::with_scope(
         |scope| {
             scope.set_tag("repo", ctx.event_repo);
-            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()
-                })),
-            );
+            // TraceContext is now managed by sentry-opentelemetry
         },
         || {
             if let Err(e) = run_ref_inner(
@@ -200,7 +188,7 @@ fn run_ref(
                 pushed_at,
                 push_ref,
                 &session,
-                sentry_trace_id.as_deref(),
+                traceparent.as_deref(),
                 ctx.sentry_dsn.as_deref(),
             ) {
                 tracing::error!(
@@ -220,7 +208,7 @@ fn run_ref_inner(
     pushed_at: jiff::Timestamp,
     push_ref: &PushRef,
     session: &ApiSession,
-    sentry_trace_id: Option<&str>,
+    traceparent: Option<&str>,
     sentry_dsn: Option<&str>,
 ) -> error::Result<()> {
     let ci = ctx.repo.ci();
@@ -258,7 +246,7 @@ fn run_ref_inner(
             run.execute(
                 &ctx.repo.path(),
                 &workspace,
-                sentry_trace_id,
+                traceparent,
                 sentry_dsn,
                 Some(session),
             )?;
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 5adf3c8..ad281c6 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -284,7 +284,7 @@ impl Run {
         mut self,
         git_dir: &Path,
         workspace: &Path,
-        sentry_trace_id: Option<&str>,
+        traceparent: Option<&str>,
         sentry_dsn: Option<&str>,
         session: Option<&ApiSession>,
     ) -> Result<()> {
@@ -329,7 +329,7 @@ impl Run {
                 cmd.arg("--local").arg("--git-dir").arg(git_dir);
             }
             Some(s) => {
-                self.store_bootstrap_data(git_dir, sentry_trace_id)?;
+                self.store_bootstrap_data(git_dir, traceparent)?;
                 cmd.env("QUIRE__SERVER_URL", &s.server_url);
                 cmd.env("QUIRE__RUN_TOKEN", &s.run_token);
             }
@@ -476,7 +476,7 @@ impl Run {
     /// Called by `execute` when the API transport is active, before spawning
     /// quire-ci. quire-ci fetches this via `GET /api/runs/:id/bootstrap`
     /// instead of reading a file.
-    fn store_bootstrap_data(&self, git_dir: &Path, sentry_trace_id: Option<&str>) -> Result<()> {
+    fn store_bootstrap_data(&self, git_dir: &Path, traceparent: Option<&str>) -> Result<()> {
         let git_dir_str = git_dir.to_str().ok_or_else(|| {
             std::io::Error::new(
                 std::io::ErrorKind::InvalidData,
@@ -485,8 +485,8 @@ impl Run {
         })?;
         let db = crate::db::open(&self.db_path)?;
         db.execute(
-            "UPDATE runs SET git_dir = ?1, sentry_trace_id = ?2 WHERE id = ?3",
-            rusqlite::params![git_dir_str, sentry_trace_id, &self.id],
+            "UPDATE runs SET git_dir = ?1, traceparent = ?2 WHERE id = ?3",
+            rusqlite::params![git_dir_str, traceparent, &self.id],
         )?;
         Ok(())
     }
diff --git a/quire-server/src/db.rs b/quire-server/src/db.rs
index e699abc..c43d1f8 100644
--- a/quire-server/src/db.rs
+++ b/quire-server/src/db.rs
@@ -17,6 +17,7 @@ static MIGRATIONS: std::sync::LazyLock<Migrations<'static>> = std::sync::LazyLoc
         M::up(include_str!("../migrations/0003_ci_api.sql")),
         M::up(include_str!("../migrations/0004_bootstrap_api.sql")),
         M::up(include_str!("../migrations/0005_rename_run_token.sql")),
+        M::up(include_str!("../migrations/0006_traceparent.sql")),
     ])
 });
 
diff --git a/quire-server/src/quire/web/api.rs b/quire-server/src/quire/web/api.rs
index 9ef61b7..a18e6cf 100644
--- a/quire-server/src/quire/web/api.rs
+++ b/quire-server/src/quire/web/api.rs
@@ -154,14 +154,14 @@ async fn get_bootstrap(
                 ref_name: String,
                 pushed_at_ms: i64,
                 git_dir: Option<String>,
-                sentry_trace_id: Option<String>,
+                traceparent: Option<String>,
                 state: String,
                 repo: String,
             }
 
             let row: RunRow = db
                 .prepare(
-                    "SELECT sha, ref_name, pushed_at_ms, git_dir, sentry_trace_id, state, repo
+                    "SELECT sha, ref_name, pushed_at_ms, git_dir, traceparent, state, repo
                      FROM runs WHERE id = ?1",
                 )?
                 .query_and_then(rusqlite::params![run_id], serde_rusqlite::from_row)?
@@ -192,7 +192,7 @@ async fn get_bootstrap(
                 git_dir,
                 repo: row.repo,
                 run_id,
-                sentry_trace_id: row.sentry_trace_id,
+                traceparent: row.traceparent,
             })
         })
         .await
@@ -284,7 +284,7 @@ mod tests {
         env: &TestEnv,
         session: &ApiSession,
         git_dir: &str,
-        sentry_trace_id: Option<&str>,
+        traceparent: Option<&str>,
     ) -> String {
         let run = env
             .runs()
@@ -294,8 +294,8 @@ mod tests {
 
         let db = crate::db::open(&env.quire.db_path()).expect("db open");
         db.execute(
-            "UPDATE runs SET git_dir = ?1, sentry_trace_id = ?2 WHERE id = ?3",
-            rusqlite::params![git_dir, sentry_trace_id, &run_id],
+            "UPDATE runs SET git_dir = ?1, traceparent = ?2 WHERE id = ?3",
+            rusqlite::params![git_dir, traceparent, &run_id],
         )
         .expect("update bootstrap data");
         run_id