Skip to main content

velesdb_core/metrics/
query.rs

1//! Query diagnostics: slow query logging, tracing spans, and duration histograms.
2//!
3//! Provides tools for:
4//! - Slow query detection and sanitized logging
5//! - Tracing span builders for query phases
6//! - Duration histograms for Prometheus export
7
8// Reason: Numeric casts in metrics are intentional:
9// - u128->u64 for millisecond durations: durations fit within u64 (thousands of years)
10// - Used for logging and monitoring, not precise calculations
11#![allow(clippy::cast_possible_truncation)]
12
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::time::Duration;
15
16use super::operational::DURATION_BUCKETS;
17
18/// Statistics about a query execution.
19#[derive(Debug, Clone, Default)]
20pub struct QueryStats {
21    /// Number of rows scanned
22    pub rows_scanned: u64,
23    /// Number of nodes visited (for graph queries)
24    pub nodes_visited: u64,
25    /// Number of vectors compared (for vector queries)
26    pub vectors_compared: u64,
27    /// Collection name
28    pub collection: String,
29}
30
31/// Slow query logger that logs queries exceeding a threshold.
32#[derive(Debug, Clone)]
33pub struct SlowQueryLogger {
34    /// Threshold duration above which queries are considered slow
35    threshold: Duration,
36    /// Whether logging is enabled
37    enabled: bool,
38}
39
40impl Default for SlowQueryLogger {
41    fn default() -> Self {
42        Self {
43            threshold: Duration::from_millis(100),
44            enabled: true,
45        }
46    }
47}
48
49impl SlowQueryLogger {
50    /// Creates a new slow query logger with the given threshold.
51    #[must_use]
52    pub fn new(threshold: Duration) -> Self {
53        Self {
54            threshold,
55            enabled: true,
56        }
57    }
58
59    /// Creates a disabled logger.
60    #[must_use]
61    pub fn disabled() -> Self {
62        Self {
63            threshold: Duration::MAX,
64            enabled: false,
65        }
66    }
67
68    /// Sets the threshold.
69    pub fn set_threshold(&mut self, threshold: Duration) {
70        self.threshold = threshold;
71    }
72
73    /// Returns true if the duration exceeds the slow query threshold.
74    #[must_use]
75    pub fn is_slow(&self, duration: Duration) -> bool {
76        self.enabled && duration >= self.threshold
77    }
78
79    /// Logs a slow query if it exceeds the threshold.
80    /// Returns true if the query was logged.
81    pub fn log_if_slow(&self, query: &str, duration: Duration, stats: &QueryStats) -> bool {
82        if !self.is_slow(duration) {
83            return false;
84        }
85
86        let sanitized = Self::sanitize_query(query);
87        tracing::warn!(
88            query = %sanitized,
89            duration_ms = duration.as_millis() as u64,
90            rows_scanned = stats.rows_scanned,
91            nodes_visited = stats.nodes_visited,
92            vectors_compared = stats.vectors_compared,
93            collection = %stats.collection,
94            "Slow query detected"
95        );
96        true
97    }
98
99    /// Sanitizes a query string by removing potential sensitive values.
100    #[must_use]
101    pub fn sanitize_query(query: &str) -> String {
102        // Remove string literals (potential PII)
103        let mut result = String::with_capacity(query.len());
104        let mut in_string = false;
105        let mut escape_next = false;
106
107        for ch in query.chars() {
108            if escape_next {
109                escape_next = false;
110                if !in_string {
111                    result.push(ch);
112                }
113                continue;
114            }
115
116            match ch {
117                '\\' => escape_next = true,
118                '"' | '\'' => {
119                    if in_string {
120                        in_string = false;
121                        result.push('?');
122                    } else {
123                        in_string = true;
124                    }
125                }
126                _ => {
127                    if !in_string {
128                        result.push(ch);
129                    }
130                }
131            }
132        }
133
134        result
135    }
136}
137
138/// Query execution phases for tracing.
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140#[non_exhaustive]
141pub enum QueryPhase {
142    /// Parsing the query
143    Parse,
144    /// Planning the execution
145    Plan,
146    /// Executing vector search
147    VectorSearch,
148    /// Executing graph traversal
149    GraphTraversal,
150    /// Fusing scores from multiple sources
151    ScoreFusion,
152    /// Filtering results
153    Filter,
154    /// Sorting and limiting results
155    Sort,
156}
157
158impl QueryPhase {
159    /// Returns the span name for this phase.
160    #[must_use]
161    pub fn span_name(self) -> &'static str {
162        match self {
163            Self::Parse => "parse",
164            Self::Plan => "plan",
165            Self::VectorSearch => "vector_search",
166            Self::GraphTraversal => "graph_traversal",
167            Self::ScoreFusion => "score_fusion",
168            Self::Filter => "filter",
169            Self::Sort => "sort",
170        }
171    }
172}
173
174/// Helper struct for creating tracing spans with consistent attributes.
175#[derive(Debug, Clone)]
176pub struct SpanBuilder {
177    /// Collection name
178    pub collection: String,
179    /// Number of rows processed
180    pub rows_processed: u64,
181    /// Additional context
182    pub context: String,
183}
184
185impl SpanBuilder {
186    /// Creates a new span builder.
187    #[must_use]
188    pub fn new(collection: impl Into<String>) -> Self {
189        Self {
190            collection: collection.into(),
191            rows_processed: 0,
192            context: String::new(),
193        }
194    }
195
196    /// Sets the number of rows processed.
197    #[must_use]
198    pub fn with_rows(mut self, rows: u64) -> Self {
199        self.rows_processed = rows;
200        self
201    }
202
203    /// Sets additional context.
204    #[must_use]
205    pub fn with_context(mut self, context: impl Into<String>) -> Self {
206        self.context = context.into();
207        self
208    }
209
210    /// Creates a tracing span for the given phase.
211    #[must_use]
212    pub fn span(&self, phase: QueryPhase) -> tracing::Span {
213        tracing::info_span!(
214            "query_phase",
215            phase = phase.span_name(),
216            collection = %self.collection,
217            rows = self.rows_processed,
218            context = %self.context
219        )
220    }
221}
222
223/// Simple histogram for query durations.
224#[derive(Debug)]
225pub struct DurationHistogram {
226    buckets: [AtomicU64; 8],
227    sum: AtomicU64, // Sum in microseconds
228    count: AtomicU64,
229}
230
231impl Default for DurationHistogram {
232    fn default() -> Self {
233        Self {
234            buckets: Default::default(),
235            sum: AtomicU64::new(0),
236            count: AtomicU64::new(0),
237        }
238    }
239}
240
241impl DurationHistogram {
242    /// Creates a new histogram.
243    #[must_use]
244    pub fn new() -> Self {
245        Self::default()
246    }
247
248    /// Observes a duration value (in seconds).
249    pub fn observe(&self, seconds: f64) {
250        self.count.fetch_add(1, Ordering::Relaxed);
251        // Reason: Duration in seconds is expected to be non-negative (timing measurement).
252        // Multiplied by 1M gives microseconds. Practical durations are << u64::MAX microseconds.
253        // Even 584,942 years in microseconds fits in u64.
254        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
255        let micros = (seconds * 1_000_000.0) as u64;
256        self.sum.fetch_add(micros, Ordering::Relaxed);
257
258        // Increment appropriate bucket
259        for (i, &bucket) in DURATION_BUCKETS.iter().enumerate() {
260            if seconds <= bucket {
261                self.buckets[i].fetch_add(1, Ordering::Relaxed);
262                return;
263            }
264        }
265        // Value exceeds all buckets — only the +Inf bucket (derived from
266        // `count`) captures it.  Do NOT increment buckets[7] here: the
267        // Prometheus export accumulates buckets cumulatively, so adding to
268        // the last named bucket would make le="5.0" include observations
269        // >5.0s, violating histogram semantics.
270    }
271
272    /// Exports histogram in Prometheus format.
273    #[must_use]
274    pub fn export_prometheus(&self, name: &str, help: &str) -> String {
275        use std::fmt::Write;
276        let mut output = String::new();
277
278        let _ = writeln!(output, "# HELP {name} {help}");
279        let _ = writeln!(output, "# TYPE {name} histogram");
280
281        let mut cumulative = 0u64;
282        for (i, &bucket_bound) in DURATION_BUCKETS.iter().enumerate() {
283            cumulative += self.buckets[i].load(Ordering::Relaxed);
284            let _ = writeln!(
285                output,
286                "{name}_bucket{{le=\"{bucket_bound}\"}} {cumulative}"
287            );
288        }
289        let _ = writeln!(
290            output,
291            "{name}_bucket{{le=\"+Inf\"}} {}",
292            self.count.load(Ordering::Relaxed)
293        );
294
295        #[allow(clippy::cast_precision_loss)]
296        let sum_secs = self.sum.load(Ordering::Relaxed) as f64 / 1_000_000.0;
297        let _ = writeln!(output, "{name}_sum {sum_secs}");
298        let _ = writeln!(
299            output,
300            "{name}_count {}",
301            self.count.load(Ordering::Relaxed)
302        );
303
304        output
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn test_slow_query_is_slow() {
314        let logger = SlowQueryLogger::new(Duration::from_millis(100));
315
316        assert!(!logger.is_slow(Duration::from_millis(50)));
317        assert!(logger.is_slow(Duration::from_millis(100)));
318        assert!(logger.is_slow(Duration::from_millis(150)));
319    }
320
321    #[test]
322    fn test_slow_query_disabled() {
323        let logger = SlowQueryLogger::disabled();
324
325        assert!(!logger.is_slow(Duration::from_secs(1000)));
326    }
327
328    #[test]
329    fn test_slow_query_sanitize() {
330        let query = r#"SELECT * FROM users WHERE name = "John Doe" AND age > 30"#;
331        let sanitized = SlowQueryLogger::sanitize_query(query);
332
333        assert!(!sanitized.contains("John Doe"));
334        assert!(sanitized.contains('?'));
335        assert!(sanitized.contains("SELECT"));
336        assert!(sanitized.contains("age > 30"));
337    }
338
339    #[test]
340    fn test_slow_query_sanitize_single_quotes() {
341        let query = "SELECT * FROM docs WHERE title = 'Secret Document'";
342        let sanitized = SlowQueryLogger::sanitize_query(query);
343
344        assert!(!sanitized.contains("Secret Document"));
345        assert!(sanitized.contains('?'));
346    }
347
348    #[test]
349    fn test_query_stats_default() {
350        let stats = QueryStats::default();
351
352        assert_eq!(stats.rows_scanned, 0);
353        assert_eq!(stats.nodes_visited, 0);
354        assert_eq!(stats.vectors_compared, 0);
355        assert!(stats.collection.is_empty());
356    }
357
358    #[test]
359    fn test_query_phase_span_names() {
360        assert_eq!(QueryPhase::Parse.span_name(), "parse");
361        assert_eq!(QueryPhase::Plan.span_name(), "plan");
362        assert_eq!(QueryPhase::VectorSearch.span_name(), "vector_search");
363        assert_eq!(QueryPhase::GraphTraversal.span_name(), "graph_traversal");
364        assert_eq!(QueryPhase::ScoreFusion.span_name(), "score_fusion");
365        assert_eq!(QueryPhase::Filter.span_name(), "filter");
366        assert_eq!(QueryPhase::Sort.span_name(), "sort");
367    }
368
369    #[test]
370    fn test_span_builder() {
371        let builder = SpanBuilder::new("test_collection")
372            .with_rows(100)
373            .with_context("test context");
374
375        assert_eq!(builder.collection, "test_collection");
376        assert_eq!(builder.rows_processed, 100);
377        assert_eq!(builder.context, "test context");
378    }
379
380    #[test]
381    fn test_span_builder_creates_span() {
382        let builder = SpanBuilder::new("my_collection").with_rows(50);
383        // Span creation should not panic (span may be disabled without subscriber)
384        let _span = builder.span(QueryPhase::VectorSearch);
385    }
386
387    #[test]
388    fn test_duration_histogram_observe() {
389        let histogram = DurationHistogram::new();
390
391        histogram.observe(0.002); // 2ms -> bucket 0.005
392        histogram.observe(0.02); // 20ms -> bucket 0.05
393        histogram.observe(0.5); // 500ms -> bucket 0.5
394
395        assert_eq!(histogram.count.load(Ordering::Relaxed), 3);
396        assert!(histogram.sum.load(Ordering::Relaxed) > 0);
397    }
398
399    #[test]
400    fn test_duration_histogram_prometheus_export() {
401        let histogram = DurationHistogram::new();
402        histogram.observe(0.01);
403        histogram.observe(0.1);
404
405        let output = histogram.export_prometheus(
406            "velesdb_query_duration_seconds",
407            "Query duration in seconds",
408        );
409
410        assert!(output.contains("velesdb_query_duration_seconds_bucket"));
411        assert!(output.contains("velesdb_query_duration_seconds_sum"));
412        assert!(output.contains("velesdb_query_duration_seconds_count 2"));
413        assert!(output.contains("le=\"+Inf\""));
414    }
415}