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)]
309#[path = "query_tests.rs"]
310mod tests;