Skip to main content

sqlx_otel/
attributes.rs

1use opentelemetry::KeyValue;
2use opentelemetry_semantic_conventions::attribute;
3
4/// Controls whether and how `db.query.text` is captured on spans.
5///
6/// Configured via [`PoolBuilder::with_query_text_mode`](crate::PoolBuilder::with_query_text_mode).
7///
8/// # Whitespace normalisation
9///
10/// For both [`Full`](Self::Full) and [`Obfuscated`](Self::Obfuscated) the emitted text has
11/// inter-token whitespace runs collapsed to a single ASCII space and leading/trailing
12/// whitespace trimmed. Whitespace **inside** string literals, quoted identifiers,
13/// dollar-quoted bodies, and comments is preserved verbatim. Multi-line SQL written across
14/// several Rust source lines therefore renders as a single readable line in `OTel` exports
15/// without the embedded `\n` and indentation runs that come from source-level formatting.
16/// [`Off`](Self::Off) is unaffected (no attribute is emitted).
17///
18/// # When to choose what
19///
20/// - **[`Full`](Self::Full)** (default) – appropriate when all SQL flows through `SQLx`
21///   bind parameters. The captured text contains placeholders (`$1`, `?`), not literal
22///   values, so user data does not leak into the span.
23/// - **[`Obfuscated`](Self::Obfuscated)** – appropriate when SQL is built via string
24///   interpolation (`format!`, query concatenation, dynamic identifiers) and may contain
25///   literal values. Structure is preserved; literals (string, numeric, hex, boolean, and
26///   `PostgreSQL` dollar-quoted) are replaced with `?`. Comments, identifiers (quoted or
27///   otherwise), operators, and `NULL` are kept verbatim.
28/// - **[`Off`](Self::Off)** – appropriate when the query text is itself sensitive
29///   (proprietary schemas, query shapes that reveal business logic) or when query-text
30///   cardinality must be eliminated entirely.
31///
32/// `db.query.parameter.<key>` capture is **not supported** – `SQLx`'s `Execute` trait does
33/// not expose bind values, and reverse-engineering them from the encoded buffer would tie
34/// the wrapper to driver internals. Callers who need per-parameter attributes can add
35/// them manually via the active span using the OpenTelemetry API.
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
37pub enum QueryTextMode {
38    /// Capture the parameterised query text. This is the default because `SQLx` queries
39    /// use bind parameters (`$1`, `?`), so literal values are not present in the query
40    /// string. Inter-token whitespace is collapsed to a single space and leading/trailing
41    /// whitespace is trimmed; whitespace inside literals, identifiers, and comments is
42    /// preserved verbatim.
43    #[default]
44    Full,
45    /// Replace literal values in the query text with `?`. Useful when queries are built
46    /// via string interpolation rather than bind parameters. The same whitespace
47    /// normalisation as [`Full`](Self::Full) is applied after redaction.
48    Obfuscated,
49    /// Do not capture `db.query.text` at all.
50    Off,
51}
52
53/// Immutable, connection-level OpenTelemetry attributes shared by every span and metric
54/// recording from a single pool.
55///
56/// Built once by [`PoolBuilder`](crate::PoolBuilder) and wrapped in `Arc` so that every
57/// wrapper type (`Pool`, `PoolConnection`, `Transaction`, `Connection`) can reference the
58/// same allocation.
59#[derive(Debug, Clone)]
60pub(crate) struct ConnectionAttributes {
61    /// `db.system.name` – always present.
62    pub system: &'static str,
63    /// `server.address` – the logical hostname (it may be `None` for embedded databases).
64    pub host: Option<String>,
65    /// `server.port`.
66    pub port: Option<u16>,
67    /// `db.namespace` – the database name.
68    pub namespace: Option<String>,
69    /// `network.peer.address` – the resolved IP address, user-provided.
70    pub network_peer_address: Option<String>,
71    /// `network.peer.port` – the resolved port, user-provided.
72    pub network_peer_port: Option<u16>,
73    /// `network.protocol.name` – the OSI L7 wire protocol (e.g. `"postgresql"`, `"mysql"`).
74    /// `None` for embedded backends that do not speak a wire protocol (e.g. `SQLite`).
75    pub network_protocol_name: Option<String>,
76    /// `network.transport` – the OSI L4 transport (`"tcp"`, `"udp"`, `"pipe"`, `"unix"`,
77    /// `"inproc"`). User-provided via [`PoolBuilder::with_network_transport`](
78    /// crate::PoolBuilder::with_network_transport); the wrapper does not infer it from the
79    /// connect string.
80    pub network_transport: Option<String>,
81    /// `db.client.connection.pool.name` – user-provided pool identifier shared with the
82    /// `db.client.connection.*` metric family. Surfaces on every span and per-operation
83    /// metric so dashboards can correlate query latency with pool-level signals.
84    pub pool_name: Option<String>,
85    /// Controls `db.query.text` capture.
86    pub query_text_mode: QueryTextMode,
87}
88
89impl ConnectionAttributes {
90    /// Produce the base `KeyValue` set for span and metric attribute lists. Only includes
91    /// attributes that have a value – optional fields are omitted when `None`.
92    pub fn base_key_values(&self) -> Vec<KeyValue> {
93        let mut attrs = Vec::with_capacity(9);
94        attrs.push(KeyValue::new(attribute::DB_SYSTEM_NAME, self.system));
95        if let Some(ref host) = self.host {
96            attrs.push(KeyValue::new(attribute::SERVER_ADDRESS, host.clone()));
97        }
98        if let Some(port) = self.port {
99            attrs.push(KeyValue::new(attribute::SERVER_PORT, i64::from(port)));
100        }
101        if let Some(ref ns) = self.namespace {
102            attrs.push(KeyValue::new(attribute::DB_NAMESPACE, ns.clone()));
103        }
104        if let Some(ref addr) = self.network_peer_address {
105            attrs.push(KeyValue::new(attribute::NETWORK_PEER_ADDRESS, addr.clone()));
106        }
107        if let Some(port) = self.network_peer_port {
108            attrs.push(KeyValue::new(attribute::NETWORK_PEER_PORT, i64::from(port)));
109        }
110        if let Some(ref proto) = self.network_protocol_name {
111            attrs.push(KeyValue::new(
112                attribute::NETWORK_PROTOCOL_NAME,
113                proto.clone(),
114            ));
115        }
116        if let Some(ref transport) = self.network_transport {
117            attrs.push(KeyValue::new(
118                attribute::NETWORK_TRANSPORT,
119                transport.clone(),
120            ));
121        }
122        if let Some(ref name) = self.pool_name {
123            attrs.push(KeyValue::new(
124                attribute::DB_CLIENT_CONNECTION_POOL_NAME,
125                name.clone(),
126            ));
127        }
128        attrs
129    }
130}
131
132/// Build a span name following the database client semconv hierarchy:
133///
134/// 1. `db.query.summary` when provided (wins unconditionally – this is the spec's
135///    designated slot for callers who cannot guarantee a low-cardinality
136///    `db.operation.name`).
137/// 2. `"{db.operation.name} {db.collection.name}"` when both are provided.
138/// 3. `"{db.operation.name}"` when only the operation is known.
139/// 4. `"{db.system.name}"` as the final fallback.
140///
141/// Empty-string inputs are treated as if absent: `Some("")` falls through to the next
142/// branch in the hierarchy. This avoids emitting empty span names – which several
143/// `OpenTelemetry` backends render as `<unnamed>` or treat as malformed – when a caller
144/// passes a vacuous annotation value.
145pub(crate) fn span_name(
146    system: &str,
147    operation: Option<&str>,
148    collection: Option<&str>,
149    summary: Option<&str>,
150) -> String {
151    fn nonempty(o: Option<&str>) -> Option<&str> {
152        o.filter(|s| !s.is_empty())
153    }
154    if let Some(s) = nonempty(summary) {
155        return s.to_owned();
156    }
157    match (nonempty(operation), nonempty(collection)) {
158        (Some(op), Some(coll)) => format!("{op} {coll}"),
159        (Some(op), None) => op.to_owned(),
160        _ => system.to_owned(),
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn span_name_with_operation_and_collection() {
170        assert_eq!(
171            span_name("postgresql", Some("SELECT"), Some("users"), None),
172            "SELECT users"
173        );
174    }
175
176    #[test]
177    fn span_name_with_operation_only() {
178        assert_eq!(
179            span_name("postgresql", Some("SELECT"), None, None),
180            "SELECT"
181        );
182    }
183
184    #[test]
185    fn span_name_fallback_to_system() {
186        assert_eq!(span_name("sqlite", None, None, None), "sqlite");
187    }
188
189    #[test]
190    fn span_name_collection_without_operation_falls_back() {
191        assert_eq!(span_name("mysql", None, Some("orders"), None), "mysql");
192    }
193
194    #[test]
195    fn span_name_summary_wins_over_operation_and_collection() {
196        assert_eq!(
197            span_name(
198                "postgresql",
199                Some("SELECT"),
200                Some("users"),
201                Some("daily report")
202            ),
203            "daily report"
204        );
205    }
206
207    #[test]
208    fn span_name_summary_alone() {
209        assert_eq!(
210            span_name("sqlite", None, None, Some("custom name")),
211            "custom name"
212        );
213    }
214
215    /// Regression: `span_name("a", Some(""), None, None)` previously returned `""`. The
216    /// minimal failing input was discovered by `span_name_is_non_empty` and shrunk by
217    /// proptest. Pinning it here so a future change cannot reintroduce the empty span
218    /// name.
219    #[test]
220    fn span_name_empty_operation_falls_through_to_system() {
221        assert_eq!(span_name("sqlite", Some(""), None, None), "sqlite");
222    }
223
224    /// Empty `summary` does not win over the rest of the hierarchy: it is treated as
225    /// missing so the `(op, coll)` synthesis still fires.
226    #[test]
227    fn span_name_empty_summary_falls_through() {
228        assert_eq!(
229            span_name("sqlite", Some("SELECT"), Some("users"), Some("")),
230            "SELECT users"
231        );
232    }
233
234    /// Empty `op` and empty `coll` together fall through to the bare-system branch.
235    #[test]
236    fn span_name_empty_op_and_coll_falls_through_to_system() {
237        assert_eq!(span_name("sqlite", Some(""), Some(""), None), "sqlite");
238    }
239
240    /// Empty `op` with non-empty `coll` still falls through, because the hierarchy
241    /// requires an operation before a collection contributes.
242    #[test]
243    fn span_name_empty_op_with_coll_falls_through_to_system() {
244        assert_eq!(span_name("sqlite", Some(""), Some("users"), None), "sqlite");
245    }
246
247    #[test]
248    fn base_key_values_all_fields() {
249        let attrs = ConnectionAttributes {
250            system: "postgresql",
251            host: Some("localhost".into()),
252            port: Some(5432),
253            namespace: Some("mydb".into()),
254            network_peer_address: Some("127.0.0.1".into()),
255            network_peer_port: Some(5432),
256            network_protocol_name: Some("postgresql".into()),
257            network_transport: Some("tcp".into()),
258            pool_name: Some("primary".into()),
259            query_text_mode: QueryTextMode::Full,
260        };
261        let kvs = attrs.base_key_values();
262        assert_eq!(kvs.len(), 9);
263        assert_eq!(kvs[0].key.as_str(), "db.system.name");
264        assert_eq!(kvs[1].key.as_str(), "server.address");
265        assert_eq!(kvs[2].key.as_str(), "server.port");
266        assert_eq!(kvs[3].key.as_str(), "db.namespace");
267        assert_eq!(kvs[4].key.as_str(), "network.peer.address");
268        assert_eq!(kvs[5].key.as_str(), "network.peer.port");
269        assert_eq!(kvs[6].key.as_str(), "network.protocol.name");
270        assert_eq!(kvs[7].key.as_str(), "network.transport");
271        assert_eq!(kvs[8].key.as_str(), "db.client.connection.pool.name");
272    }
273
274    #[test]
275    fn base_key_values_minimal() {
276        let attrs = ConnectionAttributes {
277            system: "sqlite",
278            host: None,
279            port: None,
280            namespace: None,
281            network_peer_address: None,
282            network_peer_port: None,
283            network_protocol_name: None,
284            network_transport: None,
285            pool_name: None,
286            query_text_mode: QueryTextMode::Off,
287        };
288        let kvs = attrs.base_key_values();
289        assert_eq!(kvs.len(), 1);
290        assert_eq!(kvs[0].key.as_str(), "db.system.name");
291    }
292
293    use proptest::prelude::*;
294
295    proptest! {
296        #![proptest_config(ProptestConfig::with_cases(128))]
297
298        /// `span_name` is total: every combination of `(system, op, coll, summary)`
299        /// yields a non-empty `String` provided `system` itself is non-empty. Empty
300        /// optional values (`Some("")`) fall through to the next branch in the
301        /// hierarchy, so the bare-system fallback always produces non-empty output.
302        #[test]
303        fn span_name_is_non_empty(
304            system in "[a-z]{1,16}",
305            op in proptest::option::of(".{0,64}"),
306            coll in proptest::option::of(".{0,64}"),
307            summary in proptest::option::of(".{0,64}"),
308        ) {
309            let name = span_name(&system, op.as_deref(), coll.as_deref(), summary.as_deref());
310            prop_assert!(!name.is_empty());
311        }
312
313        /// When `summary` is `Some(s)` with `s` non-empty, the output equals `s`
314        /// exactly: the summary branch wins unconditionally over the `(op, coll)`
315        /// synthesis. Empty summaries fall through and are covered by the dedicated
316        /// example test.
317        #[test]
318        fn span_name_summary_wins(
319            system in ".{0,16}",
320            op in proptest::option::of(".{0,64}"),
321            coll in proptest::option::of(".{0,64}"),
322            summary in ".{1,64}",
323        ) {
324            let name = span_name(&system, op.as_deref(), coll.as_deref(), Some(summary.as_str()));
325            prop_assert_eq!(name, summary);
326        }
327
328        /// When `summary` is `None` and both `op` and `coll` are `Some` with non-empty
329        /// values, the output is `"{op} {coll}"` exactly. Empty op/coll combinations
330        /// fall through and are covered by dedicated example tests.
331        #[test]
332        fn span_name_op_coll_synthesis(
333            system in ".{0,16}",
334            op in ".{1,64}",
335            coll in ".{1,64}",
336        ) {
337            let name = span_name(&system, Some(&op), Some(&coll), None);
338            prop_assert_eq!(name, format!("{op} {coll}"));
339        }
340
341        /// When all of `op`, `coll`, and `summary` are `None`, the output equals
342        /// `system` exactly.
343        #[test]
344        fn span_name_bare_system_fallback(system in ".{0,16}") {
345            let name = span_name(&system, None, None, None);
346            prop_assert_eq!(name, system);
347        }
348
349        /// Setting only `coll` without `op` falls through to the bare-system branch:
350        /// the spec hierarchy requires an operation before a collection contributes
351        /// to the span name.
352        #[test]
353        fn span_name_collection_alone_is_ignored(
354            system in ".{0,16}",
355            coll in ".{0,64}",
356        ) {
357            let name = span_name(&system, None, Some(&coll), None);
358            prop_assert_eq!(name, system);
359        }
360
361        /// `span_name` does not panic on any combination of arbitrary unicode, including
362        /// null bytes, multi-byte sequences, and combining characters.
363        #[test]
364        fn span_name_no_panic(
365            system in any::<String>(),
366            op in proptest::option::of(any::<String>()),
367            coll in proptest::option::of(any::<String>()),
368            summary in proptest::option::of(any::<String>()),
369        ) {
370            let _ = span_name(&system, op.as_deref(), coll.as_deref(), summary.as_deref());
371        }
372
373        /// `base_key_values` emits `1 + n` entries where `n` is the count of populated
374        /// optional fields. `db.system.name` is always present, the others appear iff
375        /// their corresponding field is `Some`.
376        #[test]
377        fn base_key_values_length_matches_populated_fields(
378            host in proptest::option::of("[a-z]{1,16}"),
379            port in proptest::option::of(any::<u16>()),
380            namespace in proptest::option::of("[a-z]{1,16}"),
381            network_peer_address in proptest::option::of("[0-9.:]{1,32}"),
382            network_peer_port in proptest::option::of(any::<u16>()),
383            network_protocol_name in proptest::option::of("[a-z]{1,16}"),
384            network_transport in proptest::option::of("[a-z]{1,8}"),
385            pool_name in proptest::option::of("[a-z0-9-]{1,32}"),
386        ) {
387            let attrs = ConnectionAttributes {
388                system: "sqlite",
389                host: host.clone(),
390                port,
391                namespace: namespace.clone(),
392                network_peer_address: network_peer_address.clone(),
393                network_peer_port,
394                network_protocol_name: network_protocol_name.clone(),
395                network_transport: network_transport.clone(),
396                pool_name: pool_name.clone(),
397                query_text_mode: QueryTextMode::Off,
398            };
399            let kvs = attrs.base_key_values();
400            let expected = 1
401                + usize::from(host.is_some())
402                + usize::from(port.is_some())
403                + usize::from(namespace.is_some())
404                + usize::from(network_peer_address.is_some())
405                + usize::from(network_peer_port.is_some())
406                + usize::from(network_protocol_name.is_some())
407                + usize::from(network_transport.is_some())
408                + usize::from(pool_name.is_some());
409            prop_assert_eq!(kvs.len(), expected);
410            prop_assert_eq!(kvs[0].key.as_str(), "db.system.name");
411
412            let keys: Vec<&str> = kvs.iter().map(|k| k.key.as_str()).collect();
413            prop_assert_eq!(
414                keys.contains(&"network.protocol.name"),
415                network_protocol_name.is_some(),
416            );
417            prop_assert_eq!(
418                keys.contains(&"network.transport"),
419                network_transport.is_some(),
420            );
421            prop_assert_eq!(
422                keys.contains(&"db.client.connection.pool.name"),
423                pool_name.is_some(),
424            );
425        }
426    }
427}