es_entity/sql_commenter.rs
1//! Correlate SQL statements with OpenTelemetry traces.
2//!
3//! [`annotate_sql`] appends a [sqlcommenter](https://google.github.io/sqlcommenter)-style
4//! `traceparent` comment carrying the currently active span's trace context to
5//! a SQL statement, e.g.
6//!
7//! ```text
8//! SELECT * FROM users WHERE id = $1 /*traceparent='00-<32-hex>-<16-hex>-01'*/
9//! ```
10//!
11//! The comment survives Postgres query normalization and appears in
12//! `pg_stat_activity` and in Postgres logs (slow query log, `auto_explain`,
13//! lock waits), so a statement observed server-side can be matched back to a
14//! distributed trace. `pg_stat_statements` retains the comment of the *first*
15//! execution seen per query id (comments are excluded from the query id
16//! jumble), which yields an exemplar trace per statement shape.
17//!
18//! # Sampling and the prepared statement cache
19//!
20//! Annotation only happens when the active span is **sampled**: an un-sampled
21//! span is never exported, so its trace id cannot be looked up in the tracing
22//! backend and annotating would be pure cost. The cost matters because an
23//! annotated statement's text is unique per span — it can never be served
24//! from sqlx's per-connection prepared statement cache and is executed
25//! non-persistently (a server-side parse + plan per execution). Gating on the
26//! sampled flag keeps full prepared-statement reuse for all un-sampled
27//! traffic.
28//!
29//! Annotation requires the `tracing-context` feature, an initialized
30//! OpenTelemetry tracing layer, and must be switched on via
31//! [`set_annotation_enabled`] (it is disabled by default). Otherwise
32//! [`annotate_sql`] is a zero-cost pass-through.
33
34use std::borrow::Cow;
35use std::sync::atomic::{AtomicBool, Ordering};
36
37static ANNOTATION_ENABLED: AtomicBool = AtomicBool::new(false);
38
39/// Globally enables or disables SQL trace-context annotation (disabled by
40/// default).
41///
42/// While disabled, [`annotate_sql`] is a pass-through and executors delegate
43/// statements untouched — prepared-statement reuse for all traffic at the
44/// cost of losing statement-level trace correlation. Intended to be set once
45/// at process startup from application configuration.
46pub fn set_annotation_enabled(enabled: bool) {
47 ANNOTATION_ENABLED.store(enabled, Ordering::Relaxed);
48}
49
50/// Whether SQL trace-context annotation is currently enabled.
51pub fn annotation_enabled() -> bool {
52 ANNOTATION_ENABLED.load(Ordering::Relaxed)
53}
54
55/// The W3C `trace-flags` sampled bit.
56#[cfg(feature = "tracing-context")]
57const SAMPLED: u8 = 0x01;
58
59/// W3C `traceparent` of the currently active span, e.g.
60/// `00-<32-hex-trace-id>-<16-hex-span-id>-01`, if that span is sampled.
61///
62/// Returns `None` when there is no valid span context (outside any span, or
63/// tracing/OTEL not initialized), when the span is not sampled (its trace is
64/// never exported, so the annotation could not be correlated with anything),
65/// or when the `tracing-context` feature is disabled.
66#[cfg(feature = "tracing-context")]
67pub fn current_traceparent() -> Option<String> {
68 crate::context::TracingContext::current()
69 .filter(|ctx| ctx.trace_flags & SAMPLED != 0)
70 .map(|ctx| ctx.traceparent)
71}
72
73/// W3C `traceparent` of the currently active span. Always `None` without the
74/// `tracing-context` feature.
75#[cfg(not(feature = "tracing-context"))]
76pub fn current_traceparent() -> Option<String> {
77 None
78}
79
80/// Appends the current sampled span's `traceparent` as a SQL comment to `sql`.
81///
82/// Returns [`Cow::Borrowed`] (no allocation) when there is no sampled span
83/// context; callers can use this to skip any annotation-dependent work.
84pub fn annotate_sql(sql: &str) -> Cow<'_, str> {
85 if !annotation_enabled() {
86 return Cow::Borrowed(sql);
87 }
88 match current_traceparent() {
89 Some(traceparent) => Cow::Owned(format!("{sql} /*traceparent='{traceparent}'*/")),
90 None => Cow::Borrowed(sql),
91 }
92}
93
94#[cfg(all(test, feature = "tracing-context"))]
95mod tests {
96 use opentelemetry::trace::TracerProvider as _;
97 use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
98
99 use super::*;
100
101 /// Serializes tests that are sensitive to the global annotation toggle.
102 static ANNOTATION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
103
104 /// Locks the toggle, tolerating poisoning from a previously panicked test.
105 fn annotation_lock() -> std::sync::MutexGuard<'static, ()> {
106 ANNOTATION_LOCK.lock().unwrap_or_else(|e| e.into_inner())
107 }
108
109 /// Enables annotation for the duration of a test, restoring the default
110 /// (disabled) on drop — including when the test panics.
111 struct AnnotationEnabled(std::sync::MutexGuard<'static, ()>);
112
113 impl AnnotationEnabled {
114 fn for_test() -> Self {
115 let guard = annotation_lock();
116 set_annotation_enabled(true);
117 Self(guard)
118 }
119 }
120
121 impl Drop for AnnotationEnabled {
122 fn drop(&mut self) {
123 set_annotation_enabled(false);
124 }
125 }
126
127 fn subscriber_with_sampler(
128 sampler: opentelemetry_sdk::trace::Sampler,
129 ) -> tracing::subscriber::DefaultGuard {
130 let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
131 .with_sampler(sampler)
132 .build();
133 let tracer = provider.tracer("sql-commenter-test");
134 tracing_subscriber::registry()
135 .with(tracing_opentelemetry::layer().with_tracer(tracer))
136 .set_default()
137 }
138
139 #[test]
140 fn no_span_context_is_noop() {
141 assert!(current_traceparent().is_none());
142 assert_eq!(annotate_sql("SELECT 1"), "SELECT 1");
143 }
144
145 #[test]
146 fn annotates_within_sampled_span() {
147 let _enabled = AnnotationEnabled::for_test();
148 let _subscriber = subscriber_with_sampler(opentelemetry_sdk::trace::Sampler::AlwaysOn);
149
150 let span = tracing::info_span!("test_span");
151 let _guard = span.enter();
152
153 let tp = current_traceparent().expect("should have traceparent in span");
154 let parts: Vec<&str> = tp.split('-').collect();
155 assert_eq!(parts.len(), 4);
156 assert_eq!(parts[0], "00");
157 assert_eq!(parts[1].len(), 32);
158 assert_eq!(parts[2].len(), 16);
159 assert_eq!(parts[3], "01");
160
161 assert_eq!(
162 annotate_sql("SELECT 1"),
163 format!("SELECT 1 /*traceparent='{tp}'*/")
164 );
165 }
166
167 #[test]
168 fn unsampled_span_is_not_annotated() {
169 // Even with annotation enabled, an un-sampled span must not be
170 // annotated: its trace is never exported, so the comment could not
171 // be correlated with anything.
172 let _enabled = AnnotationEnabled::for_test();
173 let _subscriber = subscriber_with_sampler(opentelemetry_sdk::trace::Sampler::AlwaysOff);
174
175 let span = tracing::info_span!("test_span");
176 let _guard = span.enter();
177
178 assert!(current_traceparent().is_none());
179 assert_eq!(annotate_sql("SELECT 1"), "SELECT 1");
180 }
181
182 #[test]
183 fn annotation_disabled_by_default_and_toggleable() {
184 let _lock = annotation_lock();
185 let _subscriber = subscriber_with_sampler(opentelemetry_sdk::trace::Sampler::AlwaysOn);
186
187 let span = tracing::info_span!("test_span");
188 let _guard = span.enter();
189
190 // Disabled by default: pass-through even within a sampled span.
191 assert_eq!(annotate_sql("SELECT 1"), "SELECT 1");
192
193 set_annotation_enabled(true);
194 assert!(annotate_sql("SELECT 1").contains("/*traceparent='"));
195 set_annotation_enabled(false);
196 }
197}