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