1#![allow(clippy::cast_possible_truncation)]
12
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::time::Duration;
15
16use super::operational::DURATION_BUCKETS;
17
18#[derive(Debug, Clone, Default)]
20pub struct QueryStats {
21 pub rows_scanned: u64,
23 pub nodes_visited: u64,
25 pub vectors_compared: u64,
27 pub collection: String,
29}
30
31#[derive(Debug, Clone)]
33pub struct SlowQueryLogger {
34 threshold: Duration,
36 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 #[must_use]
52 pub fn new(threshold: Duration) -> Self {
53 Self {
54 threshold,
55 enabled: true,
56 }
57 }
58
59 #[must_use]
61 pub fn disabled() -> Self {
62 Self {
63 threshold: Duration::MAX,
64 enabled: false,
65 }
66 }
67
68 pub fn set_threshold(&mut self, threshold: Duration) {
70 self.threshold = threshold;
71 }
72
73 #[must_use]
75 pub fn is_slow(&self, duration: Duration) -> bool {
76 self.enabled && duration >= self.threshold
77 }
78
79 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 #[must_use]
101 pub fn sanitize_query(query: &str) -> String {
102 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
140#[non_exhaustive]
141pub enum QueryPhase {
142 Parse,
144 Plan,
146 VectorSearch,
148 GraphTraversal,
150 ScoreFusion,
152 Filter,
154 Sort,
156}
157
158impl QueryPhase {
159 #[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#[derive(Debug, Clone)]
176pub struct SpanBuilder {
177 pub collection: String,
179 pub rows_processed: u64,
181 pub context: String,
183}
184
185impl SpanBuilder {
186 #[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 #[must_use]
198 pub fn with_rows(mut self, rows: u64) -> Self {
199 self.rows_processed = rows;
200 self
201 }
202
203 #[must_use]
205 pub fn with_context(mut self, context: impl Into<String>) -> Self {
206 self.context = context.into();
207 self
208 }
209
210 #[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#[derive(Debug)]
225pub struct DurationHistogram {
226 buckets: [AtomicU64; 8],
227 sum: AtomicU64, 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 #[must_use]
244 pub fn new() -> Self {
245 Self::default()
246 }
247
248 pub fn observe(&self, seconds: f64) {
250 self.count.fetch_add(1, Ordering::Relaxed);
251 #[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 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 }
271
272 #[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 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); histogram.observe(0.02); histogram.observe(0.5); 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}