Skip to main content

oxirs_gql/
performance_heatmap.rs

1//! Query Performance Heatmaps
2//!
3//! This module provides performance heatmap generation for visualizing query
4//! performance patterns over time and across different dimensions.
5//!
6//! # Features
7//!
8//! - **Time-based Heatmaps**: Visualize performance over time (hourly, daily)
9//! - **Operation Heatmaps**: Performance by operation type/name
10//! - **Field-level Heatmaps**: Per-field resolution performance
11//! - **Percentile Heatmaps**: P50/P95/P99 performance distribution
12//! - **Multiple Formats**: JSON, HTML, ASCII, CSV export
13//! - **Configurable Buckets**: Time windows and performance buckets
14//!
15//! # Example
16//!
17//! ```rust,ignore
18//! use oxirs_gql::performance_heatmap::{HeatmapGenerator, HeatmapConfig};
19//!
20//! let mut generator = HeatmapGenerator::new(HeatmapConfig::default());
21//! generator.record_query("GetUser", 150);
22//!
23//! let heatmap = generator.generate_time_heatmap()?;
24//! let html = heatmap.to_html();
25//! ```
26
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29use std::time::{Duration, SystemTime};
30
31/// Heatmap configuration
32#[derive(Debug, Clone)]
33pub struct HeatmapConfig {
34    /// Time bucket size (e.g., 1 hour)
35    pub time_bucket_size: Duration,
36    /// Performance buckets (ms thresholds)
37    pub performance_buckets: Vec<u64>,
38    /// Maximum data points to retain
39    pub max_data_points: usize,
40    /// Percentiles to track
41    pub percentiles: Vec<u8>,
42}
43
44impl HeatmapConfig {
45    /// Create new heatmap configuration
46    pub fn new() -> Self {
47        Self {
48            time_bucket_size: Duration::from_secs(3600), // 1 hour
49            performance_buckets: vec![10, 50, 100, 250, 500, 1000, 2500, 5000],
50            max_data_points: 10000,
51            percentiles: vec![50, 95, 99],
52        }
53    }
54
55    /// Set time bucket size
56    pub fn with_time_bucket_size(mut self, duration: Duration) -> Self {
57        self.time_bucket_size = duration;
58        self
59    }
60
61    /// Set performance buckets
62    pub fn with_performance_buckets(mut self, buckets: Vec<u64>) -> Self {
63        self.performance_buckets = buckets;
64        self
65    }
66
67    /// Set max data points
68    pub fn with_max_data_points(mut self, max: usize) -> Self {
69        self.max_data_points = max;
70        self
71    }
72}
73
74impl Default for HeatmapConfig {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80/// Query performance data point
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct PerformanceDataPoint {
83    /// Timestamp
84    pub timestamp: SystemTime,
85    /// Operation name
86    pub operation: String,
87    /// Duration in milliseconds
88    pub duration_ms: u64,
89    /// Field name (if field-level)
90    pub field: Option<String>,
91}
92
93/// Heatmap cell
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct HeatmapCell {
96    /// Time bucket start
97    pub time_bucket: u64,
98    /// Performance bucket index
99    pub performance_bucket: usize,
100    /// Count of queries in this cell
101    pub count: usize,
102    /// Average duration in this cell
103    pub avg_duration_ms: f64,
104}
105
106/// Heatmap data
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Heatmap {
109    /// Heatmap cells
110    pub cells: Vec<HeatmapCell>,
111    /// Time bucket size (seconds)
112    pub time_bucket_size: u64,
113    /// Performance buckets (ms)
114    pub performance_buckets: Vec<u64>,
115    /// Start time
116    pub start_time: SystemTime,
117    /// End time
118    pub end_time: SystemTime,
119}
120
121impl Heatmap {
122    /// Get maximum count across all cells
123    pub fn max_count(&self) -> usize {
124        self.cells.iter().map(|c| c.count).max().unwrap_or(0)
125    }
126
127    /// Get minimum count across all cells
128    pub fn min_count(&self) -> usize {
129        self.cells.iter().map(|c| c.count).min().unwrap_or(0)
130    }
131
132    /// Convert to JSON
133    pub fn to_json(&self) -> Result<String, String> {
134        serde_json::to_string_pretty(self).map_err(|e| format!("JSON serialization failed: {}", e))
135    }
136
137    /// Convert to HTML heatmap visualization
138    pub fn to_html(&self) -> String {
139        let mut html = String::from("<html><head><style>\n");
140        html.push_str(".heatmap { display: grid; gap: 2px; }\n");
141        html.push_str(".cell { padding: 10px; text-align: center; border: 1px solid #ddd; }\n");
142        html.push_str(".hot { background-color: #ff0000; color: white; }\n");
143        html.push_str(".warm { background-color: #ffaa00; }\n");
144        html.push_str(".cool { background-color: #00ff00; }\n");
145        html.push_str(".cold { background-color: #e0e0e0; }\n");
146        html.push_str("</style></head><body>\n");
147        html.push_str("<h1>Performance Heatmap</h1>\n");
148        html.push_str("<div class='heatmap'>\n");
149
150        let max_count = self.max_count() as f64;
151
152        for cell in &self.cells {
153            let intensity = if max_count > 0.0 {
154                cell.count as f64 / max_count
155            } else {
156                0.0
157            };
158
159            let class = if intensity > 0.75 {
160                "hot"
161            } else if intensity > 0.5 {
162                "warm"
163            } else if intensity > 0.25 {
164                "cool"
165            } else {
166                "cold"
167            };
168
169            html.push_str(&format!(
170                "<div class='cell {}'>Count: {}<br>Avg: {:.1}ms</div>\n",
171                class, cell.count, cell.avg_duration_ms
172            ));
173        }
174
175        html.push_str("</div></body></html>");
176        html
177    }
178
179    /// Convert to ASCII art
180    pub fn to_ascii(&self) -> String {
181        let max_count = self.max_count();
182        let mut output = String::from("Performance Heatmap:\n");
183
184        for cell in &self.cells {
185            let intensity = (cell.count * 10)
186                .checked_div(max_count)
187                .unwrap_or(0)
188                .min(10);
189
190            let bar = "█".repeat(intensity);
191            output.push_str(&format!(
192                "Bucket {}: {} ({} queries, avg {:.1}ms)\n",
193                cell.performance_bucket, bar, cell.count, cell.avg_duration_ms
194            ));
195        }
196
197        output
198    }
199
200    /// Convert to CSV
201    pub fn to_csv(&self) -> String {
202        let mut csv = String::from("time_bucket,performance_bucket,count,avg_duration_ms\n");
203
204        for cell in &self.cells {
205            csv.push_str(&format!(
206                "{},{},{},{:.2}\n",
207                cell.time_bucket, cell.performance_bucket, cell.count, cell.avg_duration_ms
208            ));
209        }
210
211        csv
212    }
213}
214
215/// Heatmap generator
216pub struct HeatmapGenerator {
217    config: HeatmapConfig,
218    data_points: Vec<PerformanceDataPoint>,
219}
220
221impl HeatmapGenerator {
222    /// Create new heatmap generator
223    pub fn new(config: HeatmapConfig) -> Self {
224        Self {
225            config,
226            data_points: Vec::new(),
227        }
228    }
229
230    /// Record a query performance data point
231    pub fn record_query(&mut self, operation: impl Into<String>, duration_ms: u64) {
232        self.record_query_at(operation, duration_ms, SystemTime::now());
233    }
234
235    /// Record query at specific time
236    pub fn record_query_at(
237        &mut self,
238        operation: impl Into<String>,
239        duration_ms: u64,
240        timestamp: SystemTime,
241    ) {
242        self.data_points.push(PerformanceDataPoint {
243            timestamp,
244            operation: operation.into(),
245            duration_ms,
246            field: None,
247        });
248
249        // Trim if exceeds max
250        if self.data_points.len() > self.config.max_data_points {
251            let excess = self.data_points.len() - self.config.max_data_points;
252            self.data_points.drain(0..excess);
253        }
254    }
255
256    /// Record field-level performance
257    pub fn record_field(
258        &mut self,
259        operation: impl Into<String>,
260        field: impl Into<String>,
261        duration_ms: u64,
262    ) {
263        self.data_points.push(PerformanceDataPoint {
264            timestamp: SystemTime::now(),
265            operation: operation.into(),
266            duration_ms,
267            field: Some(field.into()),
268        });
269
270        if self.data_points.len() > self.config.max_data_points {
271            let excess = self.data_points.len() - self.config.max_data_points;
272            self.data_points.drain(0..excess);
273        }
274    }
275
276    /// Generate time-based heatmap
277    pub fn generate_time_heatmap(&self) -> Result<Heatmap, String> {
278        if self.data_points.is_empty() {
279            return Err("No data points available".to_string());
280        }
281
282        let start_time = self
283            .data_points
284            .iter()
285            .map(|p| p.timestamp)
286            .min()
287            .expect("collection should not be empty");
288        let end_time = self
289            .data_points
290            .iter()
291            .map(|p| p.timestamp)
292            .max()
293            .expect("collection should not be empty");
294
295        let mut cells_map: HashMap<(u64, usize), Vec<u64>> = HashMap::new();
296
297        for point in &self.data_points {
298            let time_bucket = self.get_time_bucket(point.timestamp, start_time);
299            let perf_bucket = self.get_performance_bucket(point.duration_ms);
300
301            cells_map
302                .entry((time_bucket, perf_bucket))
303                .or_default()
304                .push(point.duration_ms);
305        }
306
307        let mut cells: Vec<HeatmapCell> = cells_map
308            .into_iter()
309            .map(|((time_bucket, perf_bucket), durations)| {
310                let count = durations.len();
311                let avg_duration_ms = durations.iter().sum::<u64>() as f64 / count as f64;
312
313                HeatmapCell {
314                    time_bucket,
315                    performance_bucket: perf_bucket,
316                    count,
317                    avg_duration_ms,
318                }
319            })
320            .collect();
321
322        cells.sort_by_key(|c| (c.time_bucket, c.performance_bucket));
323
324        Ok(Heatmap {
325            cells,
326            time_bucket_size: self.config.time_bucket_size.as_secs(),
327            performance_buckets: self.config.performance_buckets.clone(),
328            start_time,
329            end_time,
330        })
331    }
332
333    /// Generate operation-based heatmap
334    pub fn generate_operation_heatmap(&self) -> HashMap<String, Vec<HeatmapCell>> {
335        let mut operation_maps: HashMap<String, HashMap<usize, Vec<u64>>> = HashMap::new();
336
337        for point in &self.data_points {
338            let perf_bucket = self.get_performance_bucket(point.duration_ms);
339
340            operation_maps
341                .entry(point.operation.clone())
342                .or_default()
343                .entry(perf_bucket)
344                .or_default()
345                .push(point.duration_ms);
346        }
347
348        operation_maps
349            .into_iter()
350            .map(|(operation, buckets)| {
351                let cells: Vec<HeatmapCell> = buckets
352                    .into_iter()
353                    .map(|(perf_bucket, durations)| {
354                        let count = durations.len();
355                        let avg_duration_ms = durations.iter().sum::<u64>() as f64 / count as f64;
356
357                        HeatmapCell {
358                            time_bucket: 0,
359                            performance_bucket: perf_bucket,
360                            count,
361                            avg_duration_ms,
362                        }
363                    })
364                    .collect();
365
366                (operation, cells)
367            })
368            .collect()
369    }
370
371    /// Get time bucket for timestamp
372    fn get_time_bucket(&self, timestamp: SystemTime, start_time: SystemTime) -> u64 {
373        let elapsed = timestamp
374            .duration_since(start_time)
375            .unwrap_or_default()
376            .as_secs();
377        elapsed / self.config.time_bucket_size.as_secs()
378    }
379
380    /// Get performance bucket index
381    fn get_performance_bucket(&self, duration_ms: u64) -> usize {
382        self.config
383            .performance_buckets
384            .iter()
385            .position(|&threshold| duration_ms <= threshold)
386            .unwrap_or(self.config.performance_buckets.len())
387    }
388
389    /// Get percentile statistics
390    pub fn get_percentile_stats(&self) -> HashMap<u8, u64> {
391        if self.data_points.is_empty() {
392            return HashMap::new();
393        }
394
395        let mut durations: Vec<u64> = self.data_points.iter().map(|p| p.duration_ms).collect();
396        durations.sort_unstable();
397
398        let mut stats = HashMap::new();
399        for &percentile in &self.config.percentiles {
400            let index = (durations.len() * percentile as usize / 100).min(durations.len() - 1);
401            stats.insert(percentile, durations[index]);
402        }
403
404        stats
405    }
406
407    /// Clear all data points
408    pub fn clear(&mut self) {
409        self.data_points.clear();
410    }
411
412    /// Get data point count
413    pub fn data_point_count(&self) -> usize {
414        self.data_points.len()
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use std::time::UNIX_EPOCH;
422
423    #[test]
424    fn test_heatmap_config_creation() {
425        let config = HeatmapConfig::new()
426            .with_time_bucket_size(Duration::from_secs(1800))
427            .with_performance_buckets(vec![100, 500, 1000])
428            .with_max_data_points(5000);
429
430        assert_eq!(config.time_bucket_size.as_secs(), 1800);
431        assert_eq!(config.performance_buckets.len(), 3);
432        assert_eq!(config.max_data_points, 5000);
433    }
434
435    #[test]
436    fn test_heatmap_config_default() {
437        let config = HeatmapConfig::default();
438
439        assert_eq!(config.time_bucket_size.as_secs(), 3600);
440        assert!(!config.performance_buckets.is_empty());
441        assert_eq!(config.max_data_points, 10000);
442    }
443
444    #[test]
445    fn test_performance_data_point() {
446        let point = PerformanceDataPoint {
447            timestamp: SystemTime::now(),
448            operation: "GetUser".to_string(),
449            duration_ms: 150,
450            field: Some("user".to_string()),
451        };
452
453        assert_eq!(point.operation, "GetUser");
454        assert_eq!(point.duration_ms, 150);
455        assert_eq!(point.field, Some("user".to_string()));
456    }
457
458    #[test]
459    fn test_heatmap_generator_creation() {
460        let config = HeatmapConfig::default();
461        let generator = HeatmapGenerator::new(config);
462
463        assert_eq!(generator.data_point_count(), 0);
464    }
465
466    #[test]
467    fn test_record_query() {
468        let mut generator = HeatmapGenerator::new(HeatmapConfig::default());
469
470        generator.record_query("GetUser", 100);
471        generator.record_query("GetPosts", 250);
472
473        assert_eq!(generator.data_point_count(), 2);
474    }
475
476    #[test]
477    fn test_record_field() {
478        let mut generator = HeatmapGenerator::new(HeatmapConfig::default());
479
480        generator.record_field("GetUser", "profile", 50);
481
482        assert_eq!(generator.data_point_count(), 1);
483        assert!(generator.data_points[0].field.is_some());
484    }
485
486    #[test]
487    fn test_max_data_points() {
488        let config = HeatmapConfig::default().with_max_data_points(5);
489        let mut generator = HeatmapGenerator::new(config);
490
491        for i in 0..10 {
492            generator.record_query(format!("Query{}", i), 100);
493        }
494
495        assert_eq!(generator.data_point_count(), 5);
496    }
497
498    #[test]
499    fn test_generate_time_heatmap() {
500        let mut generator = HeatmapGenerator::new(HeatmapConfig::default());
501
502        let now = SystemTime::now();
503        generator.record_query_at("Query1", 50, now);
504        generator.record_query_at("Query2", 150, now);
505        generator.record_query_at("Query3", 500, now);
506
507        let heatmap = generator.generate_time_heatmap().expect("should succeed");
508
509        assert!(!heatmap.cells.is_empty());
510        assert_eq!(heatmap.start_time, now);
511    }
512
513    #[test]
514    fn test_generate_time_heatmap_empty() {
515        let generator = HeatmapGenerator::new(HeatmapConfig::default());
516
517        let result = generator.generate_time_heatmap();
518
519        assert!(result.is_err());
520    }
521
522    #[test]
523    fn test_generate_operation_heatmap() {
524        let mut generator = HeatmapGenerator::new(HeatmapConfig::default());
525
526        generator.record_query("GetUser", 100);
527        generator.record_query("GetUser", 150);
528        generator.record_query("GetPosts", 500);
529
530        let op_heatmap = generator.generate_operation_heatmap();
531
532        assert!(op_heatmap.contains_key("GetUser"));
533        assert!(op_heatmap.contains_key("GetPosts"));
534    }
535
536    #[test]
537    fn test_performance_bucket() {
538        let config = HeatmapConfig::default().with_performance_buckets(vec![100, 500, 1000]);
539        let generator = HeatmapGenerator::new(config);
540
541        assert_eq!(generator.get_performance_bucket(50), 0);
542        assert_eq!(generator.get_performance_bucket(250), 1);
543        assert_eq!(generator.get_performance_bucket(750), 2);
544        assert_eq!(generator.get_performance_bucket(2000), 3);
545    }
546
547    #[test]
548    fn test_get_percentile_stats() {
549        let mut generator = HeatmapGenerator::new(HeatmapConfig::default());
550
551        for i in 1..=100 {
552            generator.record_query("Query", i * 10);
553        }
554
555        let stats = generator.get_percentile_stats();
556
557        assert!(stats.contains_key(&50));
558        assert!(stats.contains_key(&95));
559        assert!(stats.contains_key(&99));
560
561        let p50 = stats.get(&50).expect("should succeed");
562        assert!(*p50 >= 400 && *p50 <= 600);
563    }
564
565    #[test]
566    fn test_percentile_stats_empty() {
567        let generator = HeatmapGenerator::new(HeatmapConfig::default());
568
569        let stats = generator.get_percentile_stats();
570
571        assert!(stats.is_empty());
572    }
573
574    #[test]
575    fn test_clear() {
576        let mut generator = HeatmapGenerator::new(HeatmapConfig::default());
577
578        generator.record_query("Query1", 100);
579        generator.record_query("Query2", 200);
580
581        assert_eq!(generator.data_point_count(), 2);
582
583        generator.clear();
584
585        assert_eq!(generator.data_point_count(), 0);
586    }
587
588    #[test]
589    fn test_heatmap_max_min_count() {
590        let cells = vec![
591            HeatmapCell {
592                time_bucket: 0,
593                performance_bucket: 0,
594                count: 10,
595                avg_duration_ms: 100.0,
596            },
597            HeatmapCell {
598                time_bucket: 1,
599                performance_bucket: 1,
600                count: 50,
601                avg_duration_ms: 200.0,
602            },
603            HeatmapCell {
604                time_bucket: 2,
605                performance_bucket: 2,
606                count: 25,
607                avg_duration_ms: 300.0,
608            },
609        ];
610
611        let heatmap = Heatmap {
612            cells,
613            time_bucket_size: 3600,
614            performance_buckets: vec![100, 500, 1000],
615            start_time: SystemTime::now(),
616            end_time: SystemTime::now(),
617        };
618
619        assert_eq!(heatmap.max_count(), 50);
620        assert_eq!(heatmap.min_count(), 10);
621    }
622
623    #[test]
624    fn test_heatmap_to_json() {
625        let heatmap = Heatmap {
626            cells: vec![HeatmapCell {
627                time_bucket: 0,
628                performance_bucket: 0,
629                count: 10,
630                avg_duration_ms: 100.0,
631            }],
632            time_bucket_size: 3600,
633            performance_buckets: vec![100, 500],
634            start_time: SystemTime::UNIX_EPOCH,
635            end_time: SystemTime::UNIX_EPOCH,
636        };
637
638        let json = heatmap.to_json().expect("should succeed");
639
640        assert!(json.contains("cells"));
641        assert!(json.contains("time_bucket_size"));
642    }
643
644    #[test]
645    fn test_heatmap_to_html() {
646        let heatmap = Heatmap {
647            cells: vec![HeatmapCell {
648                time_bucket: 0,
649                performance_bucket: 0,
650                count: 10,
651                avg_duration_ms: 100.0,
652            }],
653            time_bucket_size: 3600,
654            performance_buckets: vec![100, 500],
655            start_time: SystemTime::UNIX_EPOCH,
656            end_time: SystemTime::UNIX_EPOCH,
657        };
658
659        let html = heatmap.to_html();
660
661        assert!(html.contains("<html>"));
662        assert!(html.contains("heatmap"));
663        assert!(html.contains("Count: 10"));
664    }
665
666    #[test]
667    fn test_heatmap_to_ascii() {
668        let heatmap = Heatmap {
669            cells: vec![HeatmapCell {
670                time_bucket: 0,
671                performance_bucket: 0,
672                count: 10,
673                avg_duration_ms: 100.0,
674            }],
675            time_bucket_size: 3600,
676            performance_buckets: vec![100, 500],
677            start_time: SystemTime::UNIX_EPOCH,
678            end_time: SystemTime::UNIX_EPOCH,
679        };
680
681        let ascii = heatmap.to_ascii();
682
683        assert!(ascii.contains("Performance Heatmap"));
684        assert!(ascii.contains("Bucket"));
685    }
686
687    #[test]
688    fn test_heatmap_to_csv() {
689        let heatmap = Heatmap {
690            cells: vec![HeatmapCell {
691                time_bucket: 0,
692                performance_bucket: 0,
693                count: 10,
694                avg_duration_ms: 100.0,
695            }],
696            time_bucket_size: 3600,
697            performance_buckets: vec![100, 500],
698            start_time: SystemTime::UNIX_EPOCH,
699            end_time: SystemTime::UNIX_EPOCH,
700        };
701
702        let csv = heatmap.to_csv();
703
704        assert!(csv.contains("time_bucket,performance_bucket,count,avg_duration_ms"));
705        assert!(csv.contains("0,0,10,100"));
706    }
707
708    #[test]
709    fn test_time_bucket_calculation() {
710        let config = HeatmapConfig::default().with_time_bucket_size(Duration::from_secs(60));
711        let generator = HeatmapGenerator::new(config);
712
713        let start = UNIX_EPOCH;
714        let t1 = start + Duration::from_secs(30);
715        let t2 = start + Duration::from_secs(90);
716        let t3 = start + Duration::from_secs(150);
717
718        assert_eq!(generator.get_time_bucket(t1, start), 0);
719        assert_eq!(generator.get_time_bucket(t2, start), 1);
720        assert_eq!(generator.get_time_bucket(t3, start), 2);
721    }
722}