`sentry-tracing`: Instrumenteded sqlx queries sometimes missing from their transaction (span closed late on sqlx worker thread)
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 45/100
- issue の種類
- バグ
- 明瞭さ
- おおむね明確
- 活発さ
- 活発
- 技術スタック
- rust
- 領域
- backend, observability
調査の方向性
Start by reading the tracing integration’s span lifecycle handling, especially the on_close path described in the report, and compare it with the reproduction using sqlx 0.8.6. Check how a span retained on another thread affects transaction inclusion; the report also identifies a thread-local subscriber behavior tied to tokio-rs/tracing#1656. Done means the integration’s behavior is specified or corrected and a regression test covers the reported missing-span case.
索引モデルが issue の本文から書いたものです。
説明
Hi Sentry folks. This is an issue I encountered while debugging a seemingly flaky test around some performance tracing code.
Since such issues are quite tricky to find (and this only happened under a certain pressure), I turned to Claude Code, which helped me to come up with the following report and reproducer:
How do you use Sentry?
Self-hosted / on-premises
SDK version
0.49.3
Environment
sentry/sentry-tracing0.49.3sqlx0.8.6 (SQLite,runtime-tokio)tracing0.1,tracing-subscriber0.3.23- Linux, rustc 1.99.1
Steps to reproduce
- Wrap a sqlx SQLite query in a function annotated with
#[tracing::instrument]. - Call it inside a Sentry transaction, then finish the transaction right after the query returns. My server code does the same with
sentry-tower'sSentryHttpLayer. - Repeat many times while the machine is under load.
Minimal reproduction (a global subscriber is used on purpose, to rule out tokio-rs/tracing#1656):
Cargo.toml
[package]
name = "sentry-sqlx-repro"
version = "0.1.0"
edition = "2024"
[dependencies]
sentry = { version = "=0.49.3", default-features = false, features = ["test", "tracing"] }
sqlx = { version = "=0.8.6", default-features = false, features = ["runtime-tokio", "sqlite"] }
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = "0.3"
src/main.rs
//! A `#[tracing::instrument]`ed sqlx query inside a Sentry transaction. The span is expected to be
//! part of the transaction, but sometimes it is missing.
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use sentry::{
ClientOptions, Hub, SentryFutureExt, TransactionContext,
protocol::EnvelopeItem,
test::with_captured_envelopes_options,
};
use sqlx::SqlitePool;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
/// Number of `query` spans closed on a sqlx worker thread instead of the calling thread.
static CLOSED_ON_WORKER: AtomicUsize = AtomicUsize::new(0);
struct CloseThreadLayer;
impl<S> tracing_subscriber::Layer<S> for CloseThreadLayer
where
S: tracing::Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>,
{
fn on_close(&self, id: tracing::span::Id, ctx: tracing_subscriber::layer::Context<'_, S>) {
let on_worker = std::thread::current()
.name()
.is_some_and(|name| name.starts_with("sqlx-sqlite-worker"));
if on_worker && ctx.span(&id).is_some_and(|span| span.name() == "query") {
CLOSED_ON_WORKER.fetch_add(1, Ordering::Relaxed);
}
}
}
#[tracing::instrument(skip_all)]
async fn query(pool: &SqlitePool) {
sqlx::query("SELECT 1").execute(pool).await.unwrap();
}
fn main() {
// Global subscriber, to rule out tokio-rs/tracing#1656 (thread-local default subscriber)
tracing_subscriber::registry()
.with(sentry::integrations::tracing::layer())
.with(CloseThreadLayer)
.init();
let iterations = 2000;
let mut missing = 0;
for _ in 0..iterations {
let envelopes = with_captured_envelopes_options(
|| {
let runtime = tokio::runtime::Runtime::new().unwrap();
runtime.block_on(async {
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
let hub = Arc::new(Hub::new_from_top(Hub::current()));
async {
let tx = sentry::start_transaction(TransactionContext::new("request", "http.server"));
sentry::configure_scope(|scope| scope.set_span(Some(tx.clone().into())));
query(&pool).await;
tx.finish();
}
.bind_hub(hub)
.await;
});
},
ClientOptions::new().traces_sample_rate(1.0),
);
let spans: usize = envelopes
.iter()
.flat_map(|envelope| envelope.items())
.filter_map(|item| match item {
EnvelopeItem::Transaction(tx) => Some(tx.spans.len()),
_ => None,
})
.sum();
if spans == 0 {
missing += 1;
}
}
println!(
"query span missing from transaction in {missing}/{iterations} iterations, \
closed on sqlx worker thread in {}/{iterations} iterations",
CLOSED_ON_WORKER.load(Ordering::Relaxed)
);
}
First, build with cargo build --release.
Running 16 instances in parallel on a 16-core machine (for i in $(seq 16); do ./target/release/sentry-sqlx-repro & done; wait):
query span missing from transaction in 16/2000 iterations, closed on sqlx worker thread in 16/2000 iterations
query span missing from transaction in 15/2000 iterations, closed on sqlx worker thread in 15/2000 iterations
query span missing from transaction in 17/2000 iterations, closed on sqlx worker thread in 17/2000 iterations
...
Across all 16 instances, 234 of 32,000 query spans were missing (about 0.7%). In every instance, the number of missing spans equalled the number of spans closed on the sqlx worker thread. A single instance on an idle machine usually reports 0-1 misses.
Expected result
The query span is always part of the transaction, because the instrumented function has returned before finish() is called.
Actual result
The query span is occasionally missing from the transaction, without any error or log message.
Analysis
- For every command,
sqlx-sqlitesends a clone ofSpan::current()to its worker thread and enters it there (sqlx-sqlite0.8.6,src/connection/worker.rs:command_tx: flume::Sender<(Command, tracing::Span)>,send_async((command(tx), Span::current())), andlet _guard = span.enter();in the worker loop). - The worker drops that clone after it has sent the result back. Under load, the async caller sometimes resumes, returns from the instrumented function and finishes the transaction before the worker drops its clone.
sentry-tracingfinishes the Sentry span inon_close, i.e. only when the last handle to thetracingspan is dropped. In those cases that's on the worker thread, after the transaction was already sent, so the finished child span is discarded.
The race is in sqlx's lifetime of the span handle, but the user-visible effect is in sentry-tracing: the duration and inclusion of a span depend on who else holds a clone. sqlx is just one example; any library that propagates Span::current() to another thread or task can cause the same.
A related, test-only symptom: when the subscriber is installed with set_default (thread-local), the parent spans are never closed at all. The Registry closes parents through the closing thread's default dispatcher (tokio-rs/tracing#1656), so a whole transaction built from tracing spans can go missing. That can make tests that use the tracing integration together with sqlx flaky, possibly similar to #1112.
Possible directions
Just suggestions, I don't know the codebase well:
- Document that span inclusion depends on all clones of the
tracingspan being dropped, and that libraries like sqlx keep clones on other threads. - Optionally finish the Sentry span when the span is last exited instead of when it's closed (or offer a setting for this). This might conflict with spans that are re-entered later.
Workaround
I now create query spans with the Sentry API directly (start_child/finish around the awaited sqlx future) instead of #[instrument]. With that, the problem no longer occurs in my setup.
- 主要言語
- Rust
- スター
- 753
- フォーク
- 193
- 平均マージ
- 10時間 10分
- マージ済み PR(30日)
- 5
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドなし
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
getsentry/sentry-rust のほかの issue
-
breaking change Rust Task
難易度 2/5 1〜3時間 初心者へのやさしさ 65/100
getsentry/sentry-rust#1345 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
Pin Rust toolchainオープンRust Task
難易度 2/5 1〜3時間 初心者へのやさしさ 62/100
getsentry/sentry-rust#933 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
Rust
難易度 1/5 1時間未満 初心者へのやさしさ 35/100
getsentry/sentry-rust#1369 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
Rust Tracking Issue
難易度 5/5 1週間以上 初心者へのやさしさ 25/100
getsentry/sentry-rust#1344 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
Rust Tracking Issue
難易度 5/5 1週間以上 初心者へのやさしさ 25/100
getsentry/sentry-rust#1343 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
getsentry/sentry-rust の issue をすべて見る
似ている issue
-
accounts bug decoding
難易度 2/5 1〜3時間 初心者へのやさしさ 84/100
wboayue/rust-ibapi#1088 ·
メンテナーはふだん 1 日以内に返信
-
enhancement
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
メンテナーはふだん 1 日以内に返信
-
`mise doctor` Shows Not Installed Lazy Tools as Problems対応中かも @jdx が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 67/100
メンテナーはふだん 1 日以内に返信
-
area:kernel needs-triage
難易度 2/5 1〜3時間 初心者へのやさしさ 65/100
bherbruck/solvecraft#7179 · コメント 1 件 ·
-
priority: medium
難易度 2/5 1〜3時間 初心者へのやさしさ 61/100
storytold/photocraft#9704 · コメント 3 件 ·
メンテナーはふだん 1 日以内に返信