Skip to main content

sqlx_otel/
executor.rs

1use std::borrow::Cow;
2use std::pin::Pin;
3use std::task::{Context, Poll};
4use std::time::Instant;
5
6use futures::Stream;
7use futures::stream::BoxStream;
8use opentelemetry::trace::{SpanKind, Status, TraceContextExt, Tracer};
9use opentelemetry::{Context as OtelContext, KeyValue};
10use opentelemetry_semantic_conventions::attribute;
11
12use crate::annotations::QueryAnnotations;
13use crate::attributes::{self, ConnectionAttributes, QueryTextMode};
14use crate::database::Database;
15use crate::metrics::Metrics;
16
17// ---------------------------------------------------------------------------
18// Span helpers
19// ---------------------------------------------------------------------------
20
21/// Append the four per-query semantic convention annotation attributes
22/// (`db.operation.name`, `db.collection.name`, `db.query.summary`, `db.stored_procedure.name`) onto
23/// the supplied vector, one push per field that is `Some`. Used by both the span attribute builder
24/// and `begin_query_span`'s metric attribute list so the two emit identical annotation-derived
25/// keys.
26fn append_annotation_attrs(kv: &mut Vec<KeyValue>, annotations: Option<&QueryAnnotations>) {
27    let Some(ann) = annotations else { return };
28    if let Some(ref op) = ann.operation {
29        kv.push(KeyValue::new(attribute::DB_OPERATION_NAME, op.clone()));
30    }
31    if let Some(ref coll) = ann.collection {
32        kv.push(KeyValue::new(attribute::DB_COLLECTION_NAME, coll.clone()));
33    }
34    if let Some(ref summary) = ann.query_summary {
35        kv.push(KeyValue::new(attribute::DB_QUERY_SUMMARY, summary.clone()));
36    }
37    if let Some(ref sp) = ann.stored_procedure {
38        kv.push(KeyValue::new(
39            attribute::DB_STORED_PROCEDURE_NAME,
40            sp.clone(),
41        ));
42    }
43}
44
45/// Build span attributes for a query, combining connection-level and per-query values.
46///
47/// When `annotations` is provided, the four per-query semantic convention attributes
48/// (`db.operation.name`, `db.collection.name`, `db.query.summary`,
49/// `db.stored_procedure.name`) are included for any field that is set.
50fn build_attributes(
51    attrs: &ConnectionAttributes,
52    sql: Option<&str>,
53    annotations: Option<&QueryAnnotations>,
54) -> Vec<KeyValue> {
55    let mut kv = attrs.base_key_values();
56    append_annotation_attrs(&mut kv, annotations);
57    if let Some(sql) = sql {
58        match attrs.query_text_mode {
59            QueryTextMode::Full => {
60                kv.push(KeyValue::new(
61                    attribute::DB_QUERY_TEXT,
62                    crate::compact::compact_whitespace(sql),
63                ));
64            }
65            QueryTextMode::Obfuscated => {
66                let obfuscated = crate::obfuscate::obfuscate(sql);
67                kv.push(KeyValue::new(
68                    attribute::DB_QUERY_TEXT,
69                    crate::compact::compact_whitespace(&obfuscated),
70                ));
71            }
72            QueryTextMode::Off => {}
73        }
74    }
75    kv
76}
77
78/// Create an OpenTelemetry span for a database operation and return a context containing it.
79fn start_span(name: &str, span_attrs: Vec<KeyValue>) -> (OtelContext, Instant) {
80    let tracer = opentelemetry::global::tracer("sqlx-otel");
81    let span = tracer
82        .span_builder(name.to_owned())
83        .with_kind(SpanKind::Client)
84        .with_attributes(span_attrs)
85        .start(&tracer);
86    let cx = OtelContext::current_with_span(span);
87    (cx, Instant::now())
88}
89
90/// Start an instrumented query: derive the span name from the connection attributes and per-query
91/// annotations, build the span and metric attribute lists, and open the span.
92///
93/// Returns the span's context, the timing reference for `finish()`, and the metric attribute list.
94/// This consolidates the boilerplate that every `Executor` method shares before delegating to the
95/// inner `SQLx` call.
96///
97/// The returned `metric_attrs` mirror the bounded portion of the span attribute set: connection
98/// attributes plus the four annotation-derived attributes when present, plus error-path attributes
99/// (`error.type`, `db.response.status_code`) appended later by `record_error`. The unbounded
100/// `db.query.text` attribute is deliberately excluded; `db.query.summary` is caller-controlled and
101/// can be unbounded – that cardinality cost is inherited from the span side.
102fn begin_query_span(
103    attrs: &ConnectionAttributes,
104    sql: Option<&str>,
105    annotations: Option<&QueryAnnotations>,
106) -> (OtelContext, Instant, Vec<KeyValue>) {
107    let (op, coll, summary) = annotations.map_or((None, None, None), |a| {
108        (
109            a.operation.as_deref(),
110            a.collection.as_deref(),
111            a.query_summary.as_deref(),
112        )
113    });
114    let name = attributes::span_name(attrs.system, op, coll, summary);
115    let span_attrs = build_attributes(attrs, sql, annotations);
116    let mut metric_attrs = attrs.base_key_values();
117    append_annotation_attrs(&mut metric_attrs, annotations);
118    let (cx, start) = start_span(&name, span_attrs);
119    (cx, start, metric_attrs)
120}
121
122/// Classify a `sqlx::Error` variant into a string suitable for `error.type`.
123fn error_type(err: &sqlx::Error) -> &'static str {
124    match err {
125        sqlx::Error::Configuration(_) => "Configuration",
126        sqlx::Error::Database(_) => "Database",
127        sqlx::Error::Io(_) => "Io",
128        sqlx::Error::Tls(_) => "Tls",
129        sqlx::Error::Protocol(_) => "Protocol",
130        sqlx::Error::RowNotFound => "RowNotFound",
131        sqlx::Error::TypeNotFound { .. } => "TypeNotFound",
132        sqlx::Error::ColumnIndexOutOfBounds { .. } => "ColumnIndexOutOfBounds",
133        sqlx::Error::ColumnNotFound(_) => "ColumnNotFound",
134        sqlx::Error::ColumnDecode { .. } => "ColumnDecode",
135        sqlx::Error::Decode(_) => "Decode",
136        sqlx::Error::AnyDriverError(_) => "AnyDriverError",
137        sqlx::Error::PoolTimedOut => "PoolTimedOut",
138        sqlx::Error::PoolClosed => "PoolClosed",
139        sqlx::Error::WorkerCrashed => "WorkerCrashed",
140        sqlx::Error::Migrate(_) => "Migrate",
141        _ => "Unknown",
142    }
143}
144
145/// Record an error on the span within the given context: set status, `error.type`, and add an
146/// exception event. Also append `error.type` and `db.response.status_code` (SQLSTATE for
147/// `sqlx::Error::Database`) onto `metric_attrs` so the histogram emission carries the same
148/// error-path dimensions as the span. Single source of truth for `error_type(err)` and SQLSTATE
149/// extraction.
150fn record_error(cx: &OtelContext, err: &sqlx::Error, metric_attrs: &mut Vec<KeyValue>) {
151    let span = cx.span();
152    let kind = error_type(err);
153    span.set_status(Status::Error {
154        description: Cow::Owned(err.to_string()),
155    });
156    span.set_attribute(KeyValue::new(attribute::ERROR_TYPE, kind));
157    metric_attrs.push(KeyValue::new(attribute::ERROR_TYPE, kind));
158    // Extract SQLSTATE or database-specific error code when available.
159    if let sqlx::Error::Database(db_err) = err
160        && let Some(code) = db_err.code()
161    {
162        let code = code.into_owned();
163        span.set_attribute(KeyValue::new(
164            attribute::DB_RESPONSE_STATUS_CODE,
165            code.clone(),
166        ));
167        metric_attrs.push(KeyValue::new(attribute::DB_RESPONSE_STATUS_CODE, code));
168    }
169    span.add_event(
170        "exception",
171        vec![
172            KeyValue::new("exception.type", kind),
173            KeyValue::new("exception.message", err.to_string()),
174        ],
175    );
176}
177
178/// Record success attributes (returned rows) on the span.
179fn record_rows(cx: &OtelContext, rows: u64) {
180    cx.span().set_attribute(KeyValue::new(
181        attribute::DB_RESPONSE_RETURNED_ROWS,
182        i64::try_from(rows).unwrap_or(i64::MAX),
183    ));
184}
185
186/// Record affected rows on the span (for `execute` operations).
187fn record_affected_rows(cx: &OtelContext, rows: u64) {
188    cx.span().set_attribute(KeyValue::new(
189        "db.response.affected_rows",
190        i64::try_from(rows).unwrap_or(i64::MAX),
191    ));
192}
193
194/// End the span and record metrics. `returned_rows` is `Some` for `fetch*` paths,
195/// `affected_rows` is `Some` for `execute` paths; both are `None` for paths that report
196/// neither (e.g. `prepare` / `describe` / `execute_many`'s streaming aggregate).
197fn finish(
198    cx: &OtelContext,
199    start: Instant,
200    returned_rows: Option<u64>,
201    affected_rows: Option<u64>,
202    metrics: &Metrics,
203    attrs: &[KeyValue],
204) {
205    cx.span().end();
206    metrics.record(start.elapsed(), returned_rows, affected_rows, attrs);
207}
208
209/// Await a future, record any error on the span, then finish. Used by `execute`, `prepare`,
210/// `prepare_with`, and `describe` which share the same instrumentation pattern.
211async fn execute_instrumented<T>(
212    fut: futures::future::BoxFuture<'_, Result<T, sqlx::Error>>,
213    cx: OtelContext,
214    start: Instant,
215    metrics: std::sync::Arc<Metrics>,
216    mut metric_attrs: Vec<KeyValue>,
217) -> Result<T, sqlx::Error> {
218    let result = fut.await;
219    if let Err(err) = &result {
220        record_error(&cx, err, &mut metric_attrs);
221    }
222    finish(&cx, start, None, None, &metrics, &metric_attrs);
223    result
224}
225
226// ---------------------------------------------------------------------------
227// InstrumentedStream – keeps the span alive for streaming operations
228// ---------------------------------------------------------------------------
229
230/// Trait that determines how many rows a stream item represents.
231trait RowCounter<T> {
232    /// Return the number of rows this item contributes.
233    fn count(item: &T) -> u64;
234}
235
236/// Counts every item as one row. Used for `fetch` (which yields `Row`).
237struct CountAll;
238
239impl<T> RowCounter<T> for CountAll {
240    fn count(_item: &T) -> u64 {
241        1
242    }
243}
244
245/// Counts only `Either::Right` items as rows. Used for `fetch_many` (which yields
246/// `Either<QueryResult, Row>`).
247struct CountRight;
248
249impl<L, R> RowCounter<sqlx::Either<L, R>> for CountRight {
250    fn count(item: &sqlx::Either<L, R>) -> u64 {
251        u64::from(item.is_right())
252    }
253}
254
255/// Counts nothing. Used for `execute_many` (which yields `QueryResult`, not rows).
256struct CountNone;
257
258impl<T> RowCounter<T> for CountNone {
259    fn count(_item: &T) -> u64 {
260        0
261    }
262}
263
264/// A stream wrapper that holds an OpenTelemetry context (keeping the span alive), counts rows,
265/// and records metrics when the stream completes or is dropped.
266struct InstrumentedStream<S, C> {
267    inner: S,
268    cx: OtelContext,
269    start: Instant,
270    rows: u64,
271    metrics: std::sync::Arc<Metrics>,
272    metric_attrs: Vec<KeyValue>,
273    error_recorded: bool,
274    finished: bool,
275    _counter: std::marker::PhantomData<C>,
276}
277
278impl<S, C> InstrumentedStream<S, C> {
279    fn new(
280        inner: S,
281        cx: OtelContext,
282        start: Instant,
283        metrics: std::sync::Arc<Metrics>,
284        metric_attrs: Vec<KeyValue>,
285    ) -> Self {
286        Self {
287            inner,
288            cx,
289            start,
290            rows: 0,
291            metrics,
292            metric_attrs,
293            error_recorded: false,
294            finished: false,
295            _counter: std::marker::PhantomData,
296        }
297    }
298
299    fn complete(&mut self) {
300        if !self.finished {
301            self.finished = true;
302            record_rows(&self.cx, self.rows);
303            finish(
304                &self.cx,
305                self.start,
306                Some(self.rows),
307                None,
308                &self.metrics,
309                &self.metric_attrs,
310            );
311        }
312    }
313}
314
315// Safety: all fields are Unpin (inner S is bounded Unpin, the rest are owned values).
316// PhantomData<C> prevents auto-Unpin, so we impl it explicitly.
317impl<S: Unpin, C> Unpin for InstrumentedStream<S, C> {}
318
319impl<S, T, C> Stream for InstrumentedStream<S, C>
320where
321    S: Stream<Item = Result<T, sqlx::Error>> + Unpin,
322    C: RowCounter<T>,
323{
324    type Item = Result<T, sqlx::Error>;
325
326    fn poll_next(mut self: Pin<&mut Self>, task_cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
327        match Pin::new(&mut self.inner).poll_next(task_cx) {
328            Poll::Ready(Some(Ok(item))) => {
329                self.rows += C::count(&item);
330                Poll::Ready(Some(Ok(item)))
331            }
332            Poll::Ready(Some(Err(err))) => {
333                if !self.error_recorded {
334                    self.error_recorded = true;
335                    // Re-borrow `&mut *self` to split the disjoint-field borrow:
336                    // `record_error` needs `&self.cx` (immutable) and `&mut self.metric_attrs`
337                    // (mutable) simultaneously. Going through `Pin<&mut Self>::deref_mut`
338                    // (sound here because of the explicit `Unpin` impl below) lets the
339                    // borrow checker see the two fields as distinct.
340                    let this = &mut *self;
341                    record_error(&this.cx, &err, &mut this.metric_attrs);
342                }
343                Poll::Ready(Some(Err(err)))
344            }
345            Poll::Ready(None) => {
346                self.complete();
347                Poll::Ready(None)
348            }
349            Poll::Pending => Poll::Pending,
350        }
351    }
352}
353
354impl<S, C> Drop for InstrumentedStream<S, C> {
355    fn drop(&mut self) {
356        self.complete();
357    }
358}
359
360// ---------------------------------------------------------------------------
361// Macro to reduce Executor impl boilerplate
362// ---------------------------------------------------------------------------
363
364/// Generate the full `sqlx::Executor` implementation for one of our wrapper types.
365///
366/// Each method extracts the SQL string, builds an OpenTelemetry span with connection attributes,
367/// delegates to the inner executor, and records metrics and errors on completion.
368///
369/// Two forms are supported:
370/// - `impl_executor!(Type, self => inner)` – no annotations (passes `None`).
371/// - `impl_executor!(Type, self => inner, annotations: expr)` – per-query annotations.
372macro_rules! impl_executor {
373    ($ty:ty, $self_:ident => $inner:expr) => {
374        impl_executor!(@impl $ty, $self_ => $inner, None);
375    };
376    ($ty:ty, $self_:ident => $inner:expr, annotations: $ann:expr) => {
377        impl_executor!(@impl $ty, $self_ => $inner, $ann);
378    };
379    (@impl $ty:ty, $self_:ident => $inner:expr, $ann:expr) => {
380        impl<'c, DB> sqlx::Executor<'c> for $ty
381        where
382            DB: Database,
383            for<'a> &'a mut DB::Connection: sqlx::Executor<'a, Database = DB>,
384        {
385            type Database = DB;
386
387            /// Execute the query and return the total number of rows affected.
388            fn execute<'e, 'q: 'e, E>(
389                $self_,
390                query: E,
391            ) -> futures::future::BoxFuture<
392                'e,
393                Result<<DB as sqlx::Database>::QueryResult, sqlx::Error>,
394            >
395            where
396                E: 'q + sqlx::Execute<'q, DB>,
397                'c: 'e,
398            {
399                let query = crate::rebuilt_query::RebuiltQuery::<DB>::split(query);
400                let state = $self_.state.clone();
401                let (cx, start, mut metric_attrs) =
402                    begin_query_span(&state.attrs, Some(query.sql_str()), $ann);
403                let fut = ($inner).execute(query);
404                Box::pin(async move {
405                    let result = fut.await;
406                    let affected = match &result {
407                        Ok(qr) => {
408                            let n = DB::rows_affected(qr);
409                            record_affected_rows(&cx, n);
410                            Some(n)
411                        }
412                        Err(err) => {
413                            record_error(&cx, err, &mut metric_attrs);
414                            None
415                        }
416                    };
417                    finish(&cx, start, None, affected, &state.metrics, &metric_attrs);
418                    result
419                })
420            }
421
422            /// Execute multiple queries and return the rows affected from each query,
423            /// in a stream.
424            fn execute_many<'e, 'q: 'e, E>(
425                $self_,
426                query: E,
427            ) -> BoxStream<'e, Result<<DB as sqlx::Database>::QueryResult, sqlx::Error>>
428            where
429                E: 'q + sqlx::Execute<'q, DB>,
430                'c: 'e,
431            {
432                let query = crate::rebuilt_query::RebuiltQuery::<DB>::split(query);
433                let state = $self_.state.clone();
434                let (cx, start, metric_attrs) =
435                    begin_query_span(&state.attrs, Some(query.sql_str()), $ann);
436                let stream = ($inner).execute_many(query);
437                Box::pin(InstrumentedStream::<_, CountNone>::new(
438                    stream,
439                    cx,
440                    start,
441                    state.metrics,
442                    metric_attrs,
443                ))
444            }
445
446            /// Execute the query and return the generated results as a stream.
447            fn fetch<'e, 'q: 'e, E>(
448                $self_,
449                query: E,
450            ) -> BoxStream<'e, Result<<DB as sqlx::Database>::Row, sqlx::Error>>
451            where
452                E: 'q + sqlx::Execute<'q, DB>,
453                'c: 'e,
454            {
455                let query = crate::rebuilt_query::RebuiltQuery::<DB>::split(query);
456                let state = $self_.state.clone();
457                let (cx, start, metric_attrs) =
458                    begin_query_span(&state.attrs, Some(query.sql_str()), $ann);
459                let stream = ($inner).fetch(query);
460                Box::pin(InstrumentedStream::<_, CountAll>::new(
461                    stream,
462                    cx,
463                    start,
464                    state.metrics,
465                    metric_attrs,
466                ))
467            }
468
469            /// Execute multiple queries and return the generated results as a stream
470            /// from each query, in a stream.
471            fn fetch_many<'e, 'q: 'e, E>(
472                $self_,
473                query: E,
474            ) -> BoxStream<
475                'e,
476                Result<
477                    sqlx::Either<
478                        <DB as sqlx::Database>::QueryResult,
479                        <DB as sqlx::Database>::Row,
480                    >,
481                    sqlx::Error,
482                >,
483            >
484            where
485                E: 'q + sqlx::Execute<'q, DB>,
486                'c: 'e,
487            {
488                let query = crate::rebuilt_query::RebuiltQuery::<DB>::split(query);
489                let state = $self_.state.clone();
490                let (cx, start, metric_attrs) =
491                    begin_query_span(&state.attrs, Some(query.sql_str()), $ann);
492                let stream = ($inner).fetch_many(query);
493                Box::pin(InstrumentedStream::<_, CountRight>::new(
494                    stream,
495                    cx,
496                    start,
497                    state.metrics,
498                    metric_attrs,
499                ))
500            }
501
502            /// Execute the query and return all the generated results, collected into
503            /// a [`Vec`].
504            fn fetch_all<'e, 'q: 'e, E>(
505                $self_,
506                query: E,
507            ) -> futures::future::BoxFuture<
508                'e,
509                Result<Vec<<DB as sqlx::Database>::Row>, sqlx::Error>,
510            >
511            where
512                E: 'q + sqlx::Execute<'q, DB>,
513                'c: 'e,
514            {
515                let query = crate::rebuilt_query::RebuiltQuery::<DB>::split(query);
516                let state = $self_.state.clone();
517                let (cx, start, mut metric_attrs) =
518                    begin_query_span(&state.attrs, Some(query.sql_str()), $ann);
519                let fut = ($inner).fetch_all(query);
520                Box::pin(async move {
521                    let result = fut.await;
522                    match &result {
523                        Ok(rows) => {
524                            let count = rows.len() as u64;
525                            record_rows(&cx, count);
526                            finish(&cx, start, Some(count), None, &state.metrics, &metric_attrs);
527                        }
528                        Err(err) => {
529                            record_error(&cx, err, &mut metric_attrs);
530                            finish(&cx, start, None, None, &state.metrics, &metric_attrs);
531                        }
532                    }
533                    result
534                })
535            }
536
537            /// Execute the query and returns exactly one row.
538            fn fetch_one<'e, 'q: 'e, E>(
539                $self_,
540                query: E,
541            ) -> futures::future::BoxFuture<
542                'e,
543                Result<<DB as sqlx::Database>::Row, sqlx::Error>,
544            >
545            where
546                E: 'q + sqlx::Execute<'q, DB>,
547                'c: 'e,
548            {
549                let query = crate::rebuilt_query::RebuiltQuery::<DB>::split(query);
550                let state = $self_.state.clone();
551                let (cx, start, mut metric_attrs) =
552                    begin_query_span(&state.attrs, Some(query.sql_str()), $ann);
553                let fut = ($inner).fetch_one(query);
554                Box::pin(async move {
555                    let result = fut.await;
556                    match &result {
557                        Ok(_) => {
558                            record_rows(&cx, 1);
559                            finish(&cx, start, Some(1), None, &state.metrics, &metric_attrs);
560                        }
561                        Err(err) => {
562                            record_error(&cx, err, &mut metric_attrs);
563                            finish(&cx, start, None, None, &state.metrics, &metric_attrs);
564                        }
565                    }
566                    result
567                })
568            }
569
570            /// Execute the query and returns at most one row.
571            fn fetch_optional<'e, 'q: 'e, E>(
572                $self_,
573                query: E,
574            ) -> futures::future::BoxFuture<
575                'e,
576                Result<Option<<DB as sqlx::Database>::Row>, sqlx::Error>,
577            >
578            where
579                E: 'q + sqlx::Execute<'q, DB>,
580                'c: 'e,
581            {
582                let query = crate::rebuilt_query::RebuiltQuery::<DB>::split(query);
583                let state = $self_.state.clone();
584                let (cx, start, mut metric_attrs) =
585                    begin_query_span(&state.attrs, Some(query.sql_str()), $ann);
586                let fut = ($inner).fetch_optional(query);
587                Box::pin(async move {
588                    let result = fut.await;
589                    match &result {
590                        Ok(maybe_row) => {
591                            let count = u64::from(maybe_row.is_some());
592                            record_rows(&cx, count);
593                            finish(&cx, start, Some(count), None, &state.metrics, &metric_attrs);
594                        }
595                        Err(err) => {
596                            record_error(&cx, err, &mut metric_attrs);
597                            finish(&cx, start, None, None, &state.metrics, &metric_attrs);
598                        }
599                    }
600                    result
601                })
602            }
603
604            /// Prepare the SQL query to inspect the type information of its parameters
605            /// and results.
606            ///
607            /// Be advised that when using the `query`, `query_as`, or `query_scalar`
608            /// functions, the query is transparently prepared and executed.
609            ///
610            /// This explicit API is provided to allow access to the statement metadata
611            /// available after it prepared but before the first row is returned.
612            fn prepare<'e>(
613                $self_,
614                query: sqlx::SqlStr,
615            ) -> futures::future::BoxFuture<
616                'e,
617                Result<<DB as sqlx::Database>::Statement, sqlx::Error>,
618            >
619            where
620                'c: 'e,
621            {
622                let state = $self_.state.clone();
623                let (cx, start, metric_attrs) =
624                    begin_query_span(&state.attrs, Some(query.as_str()), $ann);
625                let fut = ($inner).prepare(query);
626                Box::pin(execute_instrumented(
627                    fut, cx, start, state.metrics, metric_attrs,
628                ))
629            }
630
631            /// Prepare the SQL query, with parameter type information, to inspect the
632            /// type information about its parameters and results.
633            ///
634            /// Only some database drivers (Postgres, MSSQL) can take advantage of
635            /// this extra information to influence parameter type inference.
636            fn prepare_with<'e>(
637                $self_,
638                sql: sqlx::SqlStr,
639                parameters: &'e [<DB as sqlx::Database>::TypeInfo],
640            ) -> futures::future::BoxFuture<
641                'e,
642                Result<<DB as sqlx::Database>::Statement, sqlx::Error>,
643            >
644            where
645                'c: 'e,
646            {
647                let state = $self_.state.clone();
648                let (cx, start, metric_attrs) =
649                    begin_query_span(&state.attrs, Some(sql.as_str()), $ann);
650                let fut = ($inner).prepare_with(sql, parameters);
651                Box::pin(execute_instrumented(
652                    fut, cx, start, state.metrics, metric_attrs,
653                ))
654            }
655
656            /// Describe the SQL query and return type information about its parameters
657            /// and results.
658            ///
659            /// This is used by compile-time verification in the query macros to
660            /// power their type inference. The macros call it on a connection they open
661            /// themselves from `DATABASE_URL`, never through this wrapper, so a span emitted
662            /// here is always a caller invoking `describe` at runtime against a live pool.
663            #[doc(hidden)]
664            fn describe<'e>(
665                $self_,
666                sql: sqlx::SqlStr,
667            ) -> futures::future::BoxFuture<
668                'e,
669                Result<sqlx::Describe<DB>, sqlx::Error>,
670            >
671            where
672                'c: 'e,
673            {
674                let state = $self_.state.clone();
675                let (cx, start, metric_attrs) =
676                    begin_query_span(&state.attrs, Some(sql.as_str()), $ann);
677                let fut = ($inner).describe(sql);
678                Box::pin(execute_instrumented(
679                    fut, cx, start, state.metrics, metric_attrs,
680                ))
681            }
682        }
683    };
684}
685
686// ---------------------------------------------------------------------------
687// Executor impls for each wrapper type
688// ---------------------------------------------------------------------------
689
690impl_executor!(&'_ crate::Pool<DB>, self => &self.inner);
691impl_executor!(&'c mut crate::PoolConnection<DB>, self => self.inner.as_mut());
692impl_executor!(&'c mut crate::Transaction<'_, DB>, self => &mut *self.inner);
693
694// Annotated wrappers – same instrumentation with per-query annotations threaded through.
695impl_executor!(
696    crate::annotations::Annotated<'c, crate::Pool<DB>>,
697    self => &self.inner.inner,
698    annotations: Some(&self.annotations)
699);
700impl_executor!(
701    crate::annotations::AnnotatedMut<'c, crate::PoolConnection<DB>>,
702    self => self.inner.inner.as_mut(),
703    annotations: Some(&self.annotations)
704);
705impl_executor!(
706    crate::annotations::AnnotatedMut<'c, crate::Transaction<'_, DB>>,
707    self => &mut *self.inner.inner,
708    annotations: Some(&self.annotations)
709);
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714    use crate::attributes::ConnectionAttributes;
715
716    #[test]
717    fn error_type_classification() {
718        // Unit variants.
719        assert_eq!(error_type(&sqlx::Error::RowNotFound), "RowNotFound");
720        assert_eq!(error_type(&sqlx::Error::PoolTimedOut), "PoolTimedOut");
721        assert_eq!(error_type(&sqlx::Error::PoolClosed), "PoolClosed");
722        assert_eq!(error_type(&sqlx::Error::WorkerCrashed), "WorkerCrashed");
723
724        // String / boxed-error variants.
725        assert_eq!(
726            error_type(&sqlx::Error::Configuration("bad".into())),
727            "Configuration"
728        );
729        assert_eq!(
730            error_type(&sqlx::Error::Io(std::io::Error::other("test"))),
731            "Io"
732        );
733        assert_eq!(error_type(&sqlx::Error::Tls("tls".into())), "Tls");
734        assert_eq!(
735            error_type(&sqlx::Error::Protocol("proto".into())),
736            "Protocol"
737        );
738        assert_eq!(error_type(&sqlx::Error::Decode("dec".into())), "Decode");
739        assert_eq!(
740            error_type(&sqlx::Error::AnyDriverError("any".into())),
741            "AnyDriverError"
742        );
743
744        // Struct variants.
745        assert_eq!(
746            error_type(&sqlx::Error::ColumnNotFound("x".into())),
747            "ColumnNotFound"
748        );
749        assert_eq!(
750            error_type(&sqlx::Error::ColumnIndexOutOfBounds { index: 5, len: 3 }),
751            "ColumnIndexOutOfBounds"
752        );
753        assert_eq!(
754            error_type(&sqlx::Error::ColumnDecode {
755                index: "0".into(),
756                source: "bad".into(),
757            }),
758            "ColumnDecode"
759        );
760        assert_eq!(
761            error_type(&sqlx::Error::TypeNotFound {
762                type_name: "Foo".into(),
763            }),
764            "TypeNotFound"
765        );
766
767        // Migrate variant (behind sqlx's "migrate" default feature).
768        assert_eq!(
769            error_type(&sqlx::Error::Migrate(Box::new(
770                sqlx::migrate::MigrateError::Execute(sqlx::Error::Protocol("test".into()))
771            ))),
772            "Migrate"
773        );
774
775        // The `_ => "Unknown"` branch covers future sqlx::Error variants that may be
776        // added in newer sqlx releases. It cannot be tested directly since we cannot
777        // construct an unknown variant, but it ensures forward compatibility.
778    }
779
780    /// `InstrumentedStream::poll_next`'s `error_recorded` guard prevents `record_error`
781    /// from running more than once when the underlying stream yields multiple `Err`s
782    /// before terminating. Without the guard, the metric's attribute slice would
783    /// accumulate duplicate `error.type` (and `db.response.status_code`) `KeyValue`s,
784    /// producing a malformed histogram data point. Driven directly via a mock stream so
785    /// the assertion does not depend on backend stream-termination semantics.
786    #[test]
787    fn instrumented_stream_records_error_only_once_when_polled_past_err() {
788        use futures::StreamExt as _;
789        use futures::executor::block_on;
790        use futures::stream;
791
792        let metrics = std::sync::Arc::new(crate::metrics::Metrics::new());
793        let metric_attrs = vec![KeyValue::new(attribute::DB_SYSTEM_NAME, "postgresql")];
794        let (cx, start) = start_span("test", Vec::new());
795
796        // Yield two distinct `Err`s back-to-back, then `None`.
797        let inner = stream::iter(vec![
798            Err::<u64, _>(sqlx::Error::ColumnNotFound("x".into())),
799            Err(sqlx::Error::ColumnNotFound("y".into())),
800        ]);
801        let mut s = InstrumentedStream::<_, CountAll>::new(inner, cx, start, metrics, metric_attrs);
802
803        block_on(async {
804            assert!(matches!(s.next().await, Some(Err(_))), "expected first Err");
805            assert!(
806                matches!(s.next().await, Some(Err(_))),
807                "expected second Err"
808            );
809            assert!(s.next().await.is_none(), "expected stream to terminate");
810        });
811
812        let error_type_count = s
813            .metric_attrs
814            .iter()
815            .filter(|kv| kv.key.as_str() == "error.type")
816            .count();
817        assert_eq!(
818            error_type_count, 1,
819            "error.type must appear exactly once even when the stream yields multiple Err items",
820        );
821        assert!(
822            s.error_recorded,
823            "error_recorded should latch true after the first Err",
824        );
825    }
826
827    fn test_attrs() -> ConnectionAttributes {
828        ConnectionAttributes {
829            system: "postgresql",
830            host: Some("localhost".into()),
831            port: Some(5432),
832            namespace: Some("mydb".into()),
833            network_peer_address: None,
834            network_peer_port: None,
835            network_protocol_name: None,
836            network_transport: None,
837            pool_name: None,
838            query_text_mode: QueryTextMode::Full,
839        }
840    }
841
842    // ===========================================================================
843    // query text
844    // ===========================================================================
845
846    #[test]
847    fn build_attributes_with_full_query_text() {
848        let attrs = test_attrs();
849        let kv = build_attributes(&attrs, Some("SELECT 1"), None);
850        let keys: Vec<&str> = kv.iter().map(|k| k.key.as_str()).collect();
851        assert!(keys.contains(&"db.query.text"));
852    }
853
854    #[test]
855    fn build_attributes_with_off_query_text() {
856        let mut attrs = test_attrs();
857        attrs.query_text_mode = QueryTextMode::Off;
858        let kv = build_attributes(&attrs, Some("SELECT 1"), None);
859        let keys: Vec<&str> = kv.iter().map(|k| k.key.as_str()).collect();
860        assert!(!keys.contains(&"db.query.text"));
861    }
862
863    #[test]
864    fn build_attributes_obfuscated_replaces_literals() {
865        let mut attrs = test_attrs();
866        attrs.query_text_mode = QueryTextMode::Obfuscated;
867        let kv = build_attributes(
868            &attrs,
869            Some("INSERT INTO t (id, name) VALUES (1, 'alice')"),
870            None,
871        );
872        let text = kv
873            .iter()
874            .find(|k| k.key.as_str() == "db.query.text")
875            .map(|k| k.value.clone());
876        assert_eq!(
877            text,
878            Some(opentelemetry::Value::String(
879                "INSERT INTO t (id, name) VALUES (?, ?)".into()
880            ))
881        );
882    }
883
884    // ===========================================================================
885    // annotations
886    // ===========================================================================
887
888    #[test]
889    fn build_attributes_no_sql_no_annotations() {
890        let attrs = test_attrs();
891        let kv = build_attributes(&attrs, None, None);
892        let keys: Vec<&str> = kv.iter().map(|k| k.key.as_str()).collect();
893        assert!(!keys.contains(&"db.query.text"));
894        assert!(!keys.contains(&"db.operation.name"));
895        assert!(!keys.contains(&"db.collection.name"));
896        assert!(!keys.contains(&"db.query.summary"));
897        assert!(!keys.contains(&"db.stored_procedure.name"));
898        assert!(keys.contains(&"db.system.name"));
899    }
900
901    #[test]
902    fn build_attributes_with_all_annotation_fields() {
903        let attrs = test_attrs();
904        let ann = QueryAnnotations::new()
905            .operation("SELECT")
906            .collection("users")
907            .query_summary("SELECT users")
908            .stored_procedure("sp_get");
909        let kv = build_attributes(&attrs, Some("SELECT * FROM users"), Some(&ann));
910        let find = |key: &str| {
911            kv.iter()
912                .find(|k| k.key.as_str() == key)
913                .map(|k| k.value.clone())
914        };
915        assert_eq!(
916            find("db.operation.name"),
917            Some(opentelemetry::Value::String("SELECT".into()))
918        );
919        assert_eq!(
920            find("db.collection.name"),
921            Some(opentelemetry::Value::String("users".into()))
922        );
923        assert_eq!(
924            find("db.query.summary"),
925            Some(opentelemetry::Value::String("SELECT users".into()))
926        );
927        assert_eq!(
928            find("db.stored_procedure.name"),
929            Some(opentelemetry::Value::String("sp_get".into()))
930        );
931        assert_eq!(
932            find("db.query.text"),
933            Some(opentelemetry::Value::String("SELECT * FROM users".into()))
934        );
935    }
936
937    #[test]
938    fn append_annotation_attrs_pushes_all_four_when_set() {
939        let ann = QueryAnnotations::new()
940            .operation("SELECT")
941            .collection("users")
942            .query_summary("users by id")
943            .stored_procedure("sp_get_users");
944        let mut kv = Vec::new();
945        append_annotation_attrs(&mut kv, Some(&ann));
946        let pairs: Vec<(&str, &opentelemetry::Value)> =
947            kv.iter().map(|k| (k.key.as_str(), &k.value)).collect();
948        assert_eq!(pairs.len(), 4, "expected one push per annotation field");
949        assert!(pairs.contains(&(
950            "db.operation.name",
951            &opentelemetry::Value::String("SELECT".into())
952        )));
953        assert!(pairs.contains(&(
954            "db.collection.name",
955            &opentelemetry::Value::String("users".into())
956        )));
957        assert!(pairs.contains(&(
958            "db.query.summary",
959            &opentelemetry::Value::String("users by id".into())
960        )));
961        assert!(pairs.contains(&(
962            "db.stored_procedure.name",
963            &opentelemetry::Value::String("sp_get_users".into())
964        )));
965    }
966
967    #[test]
968    fn append_annotation_attrs_none_pushes_nothing() {
969        let mut kv = Vec::new();
970        append_annotation_attrs(&mut kv, None);
971        assert!(kv.is_empty(), "no pushes expected when annotations is None");
972    }
973
974    #[test]
975    fn append_annotation_attrs_default_pushes_nothing() {
976        let mut kv = Vec::new();
977        append_annotation_attrs(&mut kv, Some(&QueryAnnotations::new()));
978        assert!(
979            kv.is_empty(),
980            "no pushes expected when every annotation field is None"
981        );
982    }
983
984    #[test]
985    fn build_attributes_annotation_field_permutations() {
986        type Setter = fn(QueryAnnotations) -> QueryAnnotations;
987
988        let attrs = test_attrs();
989        let fields: &[(&str, Setter)] = &[
990            ("db.operation.name", |a| a.operation("SELECT")),
991            ("db.collection.name", |a| a.collection("users")),
992            ("db.query.summary", |a| a.query_summary("SELECT users")),
993            ("db.stored_procedure.name", |a| a.stored_procedure("sp")),
994        ];
995
996        // Verify every permutation (2^4 = 16) of the four annotation fields: each field that is
997        // `Some` must appear in the output, and each field that is `None` must be absent.
998        for mask in 0u8..16 {
999            let mut ann = QueryAnnotations::new();
1000            for (i, &(_, setter)) in fields.iter().enumerate() {
1001                if mask & (1 << i) != 0 {
1002                    ann = setter(ann);
1003                }
1004            }
1005            let kv = build_attributes(&attrs, None, Some(&ann));
1006            let keys: Vec<&str> = kv.iter().map(|k| k.key.as_str()).collect();
1007            for (i, &(key, _)) in fields.iter().enumerate() {
1008                println!(
1009                    "mask: {:08b}, field: {}, key: {}; contains: {}",
1010                    mask,
1011                    i,
1012                    key,
1013                    keys.contains(&key)
1014                );
1015                if mask & (1 << i) != 0 {
1016                    assert!(
1017                        keys.contains(&key),
1018                        "{key} should be present for mask {mask:#06b}"
1019                    );
1020                } else {
1021                    assert!(
1022                        !keys.contains(&key),
1023                        "{key} should be absent for mask {mask:#06b}"
1024                    );
1025                }
1026            }
1027        }
1028    }
1029
1030    use proptest::prelude::*;
1031
1032    /// Build a `ConnectionAttributes` from explicit option fields. Used by the proptest
1033    /// strategies below so that each generated case exercises an arbitrary subset of the
1034    /// optional connection-level fields.
1035    fn make_connection_attributes(
1036        host: Option<String>,
1037        port: Option<u16>,
1038        namespace: Option<String>,
1039        network_peer_address: Option<String>,
1040        network_peer_port: Option<u16>,
1041        query_text_mode: QueryTextMode,
1042    ) -> ConnectionAttributes {
1043        ConnectionAttributes {
1044            system: "postgresql",
1045            host,
1046            port,
1047            namespace,
1048            network_peer_address,
1049            network_peer_port,
1050            network_protocol_name: None,
1051            network_transport: None,
1052            pool_name: None,
1053            query_text_mode,
1054        }
1055    }
1056
1057    /// Strategy for the three `QueryTextMode` variants.
1058    fn any_query_text_mode() -> impl Strategy<Value = QueryTextMode> {
1059        prop_oneof![
1060            Just(QueryTextMode::Full),
1061            Just(QueryTextMode::Obfuscated),
1062            Just(QueryTextMode::Off),
1063        ]
1064    }
1065
1066    /// Sentinel embedded inside marked literals for the chain no-leak proptest. Mirrors
1067    /// the constant in `obfuscate::tests::proptests` so a single failure mode (a literal
1068    /// kind escaping redaction) is detected through both the standalone `obfuscate`
1069    /// invariants and the executor-level chain invariants. The chain generators below
1070    /// intentionally duplicate the token shapes from `obfuscate::tests::proptests` and
1071    /// `compact::tests::proptests`; if a token shape needs adjusting, mirror the change
1072    /// in all three modules so the chain invariants stay honest.
1073    const CHAIN_SENTINEL: &str = "XSECRETX";
1074
1075    /// Minimal fragment generator for the chain proptests: covers the token kinds whose
1076    /// composition through `obfuscate -> compact_whitespace` exercises every region of
1077    /// both state machines. Bodies are alphabetic-and-digit so the sentinel cannot
1078    /// accidentally appear in a non-literal token.
1079    fn chain_fragment_any() -> impl Strategy<Value = String> {
1080        let token = prop_oneof![
1081            "[a-z_][a-z0-9_]{0,7}".prop_map(String::from),
1082            "[ \t\n]{0,5}".prop_map(String::from),
1083            "[a-z0-9 _]{0,8}".prop_map(|inner| format!("'{inner}'")),
1084            "[a-z0-9 _]{0,8}".prop_map(|inner| format!("\"{inner}\"")),
1085            (
1086                "[a-z_]{0,3}".prop_map(String::from),
1087                "[a-z0-9 _]{0,8}".prop_map(String::from),
1088            )
1089                .prop_map(|(tag, body)| format!("${tag}${body}${tag}$")),
1090            "[a-z0-9 _]{0,12}".prop_map(|inner| format!("--{inner}\n")),
1091            "[a-z0-9 _]{0,12}".prop_map(|inner| format!("/*{inner}*/")),
1092            "[0-9]{1,5}".prop_map(String::from),
1093            prop::sample::select(vec![",", ";", "=", "(", ")", "+", "*", "?"])
1094                .prop_map(String::from),
1095        ];
1096        prop::collection::vec(token, 0..12).prop_map(|tokens| tokens.concat())
1097    }
1098
1099    /// Marked-literal fragment generator: every string and dollar-quoted body embeds the
1100    /// sentinel. Surrounding tokens never contain the sentinel because their bodies are
1101    /// lowercase-only. If any literal kind is not redacted by `obfuscate`, the sentinel
1102    /// leaks through to the chain output.
1103    fn chain_fragment_marked() -> impl Strategy<Value = String> {
1104        let token = prop_oneof![
1105            "[a-z_][a-z0-9_]{0,7}".prop_map(String::from),
1106            "[ \t\n]{0,5}".prop_map(String::from),
1107            Just(format!("'{CHAIN_SENTINEL}'")),
1108            "[a-z_]{0,3}".prop_map(|tag| format!("${tag}${CHAIN_SENTINEL}${tag}$")),
1109            prop::sample::select(vec![",", ";", "=", "(", ")"]).prop_map(String::from),
1110        ];
1111        prop::collection::vec(token, 0..10).prop_map(|tokens| tokens.concat())
1112    }
1113
1114    /// Strategy for an arbitrary `QueryAnnotations` whose four fields are independently
1115    /// `None` or `Some(s)` for a bounded-length string `s`.
1116    fn any_annotations() -> impl Strategy<Value = QueryAnnotations> {
1117        (
1118            proptest::option::of(".{0,32}"),
1119            proptest::option::of(".{0,32}"),
1120            proptest::option::of(".{0,32}"),
1121            proptest::option::of(".{0,32}"),
1122        )
1123            .prop_map(|(op, coll, summary, sp)| {
1124                let mut ann = QueryAnnotations::new();
1125                if let Some(s) = op {
1126                    ann = ann.operation(s);
1127                }
1128                if let Some(s) = coll {
1129                    ann = ann.collection(s);
1130                }
1131                if let Some(s) = summary {
1132                    ann = ann.query_summary(s);
1133                }
1134                if let Some(s) = sp {
1135                    ann = ann.stored_procedure(s);
1136                }
1137                ann
1138            })
1139    }
1140
1141    proptest! {
1142        #![proptest_config(ProptestConfig::with_cases(128))]
1143
1144        /// Membership invariant: the keys emitted by `build_attributes` are exactly the
1145        /// union of the base connection keys, the four annotation keys (each iff its
1146        /// field is `Some`), and `db.query.text` (iff `sql.is_some()` and the mode is
1147        /// not `Off`).
1148        #[test]
1149        fn build_attributes_membership_invariant(
1150            host in proptest::option::of("[a-z]{1,16}"),
1151            port in proptest::option::of(any::<u16>()),
1152            namespace in proptest::option::of("[a-z]{1,16}"),
1153            network_peer_address in proptest::option::of("[0-9.:]{1,32}"),
1154            network_peer_port in proptest::option::of(any::<u16>()),
1155            mode in any_query_text_mode(),
1156            sql in proptest::option::of(".{0,64}"),
1157            ann in any_annotations(),
1158        ) {
1159            let attrs = make_connection_attributes(
1160                host.clone(), port, namespace.clone(),
1161                network_peer_address.clone(), network_peer_port, mode,
1162            );
1163            let kv = build_attributes(&attrs, sql.as_deref(), Some(&ann));
1164            let keys: Vec<&str> = kv.iter().map(|k| k.key.as_str()).collect();
1165
1166            // `db.system.name` is always present.
1167            prop_assert!(keys.contains(&"db.system.name"));
1168
1169            // Optional connection keys appear iff their field is `Some`.
1170            prop_assert_eq!(keys.contains(&"server.address"), host.is_some());
1171            prop_assert_eq!(keys.contains(&"server.port"), port.is_some());
1172            prop_assert_eq!(keys.contains(&"db.namespace"), namespace.is_some());
1173            prop_assert_eq!(keys.contains(&"network.peer.address"), network_peer_address.is_some());
1174            prop_assert_eq!(keys.contains(&"network.peer.port"), network_peer_port.is_some());
1175
1176            // Annotation keys appear iff their field is `Some`.
1177            prop_assert_eq!(keys.contains(&"db.operation.name"), ann.operation.is_some());
1178            prop_assert_eq!(keys.contains(&"db.collection.name"), ann.collection.is_some());
1179            prop_assert_eq!(keys.contains(&"db.query.summary"), ann.query_summary.is_some());
1180            prop_assert_eq!(keys.contains(&"db.stored_procedure.name"), ann.stored_procedure.is_some());
1181
1182            // `db.query.text` is emitted iff sql is provided and mode is not Off.
1183            let expect_query_text = sql.is_some() && mode != QueryTextMode::Off;
1184            prop_assert_eq!(keys.contains(&"db.query.text"), expect_query_text);
1185        }
1186
1187        /// No key appears more than once in the emitted attribute list. Duplicate keys
1188        /// would cause downstream OTel exporters to emit conflicting tag values.
1189        #[test]
1190        fn build_attributes_has_no_duplicate_keys(
1191            host in proptest::option::of("[a-z]{1,16}"),
1192            port in proptest::option::of(any::<u16>()),
1193            namespace in proptest::option::of("[a-z]{1,16}"),
1194            mode in any_query_text_mode(),
1195            sql in proptest::option::of(".{0,64}"),
1196            ann in any_annotations(),
1197        ) {
1198            let attrs = make_connection_attributes(host, port, namespace, None, None, mode);
1199            let kv = build_attributes(&attrs, sql.as_deref(), Some(&ann));
1200            let mut seen = std::collections::HashSet::new();
1201            for k in &kv {
1202                prop_assert!(
1203                    seen.insert(k.key.as_str().to_owned()),
1204                    "duplicate key in build_attributes output: {}",
1205                    k.key.as_str(),
1206                );
1207            }
1208        }
1209
1210        /// `build_attributes` does not panic on arbitrary unicode SQL across all three
1211        /// query-text modes, including the obfuscated path that delegates into
1212        /// `obfuscate::obfuscate`.
1213        #[test]
1214        fn build_attributes_no_panic_arbitrary_sql(
1215            sql in proptest::option::of(any::<String>()),
1216            mode in any_query_text_mode(),
1217            ann in any_annotations(),
1218        ) {
1219            let attrs = make_connection_attributes(None, None, None, None, None, mode);
1220            let _ = build_attributes(&attrs, sql.as_deref(), Some(&ann));
1221        }
1222
1223        /// When `annotations` is `None`, no annotation keys appear in the output
1224        /// regardless of any other input – the `if let Some(ann)` guard short-circuits
1225        /// the entire annotation-emission block.
1226        #[test]
1227        fn build_attributes_no_annotations_emits_no_annotation_keys(
1228            mode in any_query_text_mode(),
1229            sql in proptest::option::of(".{0,64}"),
1230        ) {
1231            let attrs = make_connection_attributes(None, None, None, None, None, mode);
1232            let kv = build_attributes(&attrs, sql.as_deref(), None);
1233            let keys: Vec<&str> = kv.iter().map(|k| k.key.as_str()).collect();
1234            prop_assert!(!keys.contains(&"db.operation.name"));
1235            prop_assert!(!keys.contains(&"db.collection.name"));
1236            prop_assert!(!keys.contains(&"db.query.summary"));
1237            prop_assert!(!keys.contains(&"db.stored_procedure.name"));
1238        }
1239
1240        /// Chain idempotence for the `Obfuscated` arm pipeline:
1241        /// `compact_whitespace(obfuscate(s))` is a fixed point. Both passes are
1242        /// individually idempotent (proven in their own modules); their composition must
1243        /// also be – running the chain twice produces the same string as running it once.
1244        #[test]
1245        fn chain_compact_obfuscate_idempotent(s in chain_fragment_any()) {
1246            let f = |x: &str| crate::compact::compact_whitespace(&crate::obfuscate::obfuscate(x));
1247            let once = f(&s);
1248            let twice = f(&once);
1249            prop_assert_eq!(once, twice);
1250        }
1251
1252        /// No-leak through chain: every literal in the input embeds the sentinel
1253        /// `XSECRETX`. After `compact_whitespace(obfuscate(s))`, the sentinel must be
1254        /// gone – otherwise some literal kind escaped redaction or the compaction step
1255        /// introduced a path that re-exposed redacted bytes.
1256        #[test]
1257        fn chain_compact_obfuscate_no_leak(s in chain_fragment_marked()) {
1258            let f = |x: &str| crate::compact::compact_whitespace(&crate::obfuscate::obfuscate(x));
1259            let out = f(&s);
1260            prop_assert!(
1261                !out.contains("XSECRETX"),
1262                "sentinel leaked through chain: input={s:?} output={out:?}"
1263            );
1264        }
1265
1266        /// Trim invariant on the emitted `db.query.text`: for both `Full` and
1267        /// `Obfuscated` modes, the captured value never starts or ends with `' '`.
1268        /// Trailing `'\n'` is permitted (line-comment terminator); only `' '` is
1269        /// forbidden as a leading or trailing byte.
1270        #[test]
1271        fn chain_emitted_query_text_trim_invariant(
1272            sql in any::<String>(),
1273            mode in prop_oneof![
1274                Just(QueryTextMode::Full),
1275                Just(QueryTextMode::Obfuscated),
1276            ],
1277        ) {
1278            let attrs = make_connection_attributes(None, None, None, None, None, mode);
1279            let kv = build_attributes(&attrs, Some(&sql), None);
1280            let value = kv
1281                .iter()
1282                .find(|k| k.key.as_str() == "db.query.text")
1283                .map(|k| k.value.clone());
1284            // For Full/Obfuscated with sql=Some, db.query.text must be present. If the
1285            // key disappears or its value type drifts away from String, the assertions
1286            // below would silently pass – fail loudly instead so a future regression in
1287            // the dispatch site is caught.
1288            let value = value.expect("db.query.text must be emitted for Full/Obfuscated");
1289            let opentelemetry::Value::String(s) = value else {
1290                panic!("db.query.text must be a String value, got {value:?}");
1291            };
1292            let s = s.as_str();
1293            prop_assert!(!s.starts_with(' '), "leading space in db.query.text: {s:?}");
1294            prop_assert!(!s.ends_with(' '), "trailing space in db.query.text: {s:?}");
1295        }
1296
1297        /// `append_annotation_attrs` membership invariant: starting from an empty vector,
1298        /// the appended key set is exactly `{"db.operation.name" iff op.is_some(),
1299        /// "db.collection.name" iff coll.is_some(), "db.query.summary" iff
1300        /// query_summary.is_some(), "db.stored_procedure.name" iff
1301        /// stored_procedure.is_some()}` – and nothing else, in particular none of the
1302        /// connection or query-text keys leak through.
1303        #[test]
1304        fn append_annotation_attrs_membership_invariant(ann in any_annotations()) {
1305            let mut kv = Vec::new();
1306            append_annotation_attrs(&mut kv, Some(&ann));
1307            let keys: Vec<&str> = kv.iter().map(|k| k.key.as_str()).collect();
1308
1309            prop_assert_eq!(keys.contains(&"db.operation.name"), ann.operation.is_some());
1310            prop_assert_eq!(keys.contains(&"db.collection.name"), ann.collection.is_some());
1311            prop_assert_eq!(keys.contains(&"db.query.summary"), ann.query_summary.is_some());
1312            prop_assert_eq!(
1313                keys.contains(&"db.stored_procedure.name"),
1314                ann.stored_procedure.is_some(),
1315            );
1316
1317            // No connection or query-text keys leak in from a stray copy-paste of
1318            // `build_attributes` semantics.
1319            prop_assert!(!keys.contains(&"db.system.name"));
1320            prop_assert!(!keys.contains(&"db.namespace"));
1321            prop_assert!(!keys.contains(&"db.query.text"));
1322
1323            // Cardinality matches the count of `Some` annotation fields.
1324            let expected_count = usize::from(ann.operation.is_some())
1325                + usize::from(ann.collection.is_some())
1326                + usize::from(ann.query_summary.is_some())
1327                + usize::from(ann.stored_procedure.is_some());
1328            prop_assert_eq!(kv.len(), expected_count);
1329        }
1330    }
1331}