Skip to main content

hammerwork_web/api/
stats.rs

1//! Statistics and monitoring API endpoints.
2//!
3//! This module provides comprehensive monitoring and analytics endpoints for tracking
4//! system health, performance metrics, and operational insights across all job queues.
5//!
6//! # API Endpoints
7//!
8//! - `GET /api/stats/overview` - System overview with key metrics
9//! - `GET /api/stats/detailed` - Detailed statistics with historical trends
10//! - `GET /api/stats/trends` - Hourly/daily trend analysis
11//! - `GET /api/stats/health` - System health check and alerts
12//!
13//! # Examples
14//!
15//! ## System Overview
16//!
17//! ```rust
18//! use hammerwork_web::api::stats::{SystemOverview, SystemHealth, SystemAlert};
19//! use chrono::Utc;
20//!
21//! let overview = SystemOverview {
22//!     total_queues: 5,
23//!     total_jobs: 10000,
24//!     pending_jobs: 50,
25//!     running_jobs: 10,
26//!     completed_jobs: 9800,
27//!     failed_jobs: 125,
28//!     dead_jobs: 15,
29//!     overall_throughput: 150.5,
30//!     overall_error_rate: 0.0125,
31//!     avg_processing_time_ms: 250.0,
32//!     system_health: SystemHealth {
33//!         status: "healthy".to_string(),
34//!         database_healthy: true,
35//!         high_error_rate: false,
36//!         queue_backlog: false,
37//!         slow_processing: false,
38//!         alerts: vec![],
39//!     },
40//!     uptime_seconds: 86400,
41//!     last_updated: Utc::now(),
42//! };
43//!
44//! assert_eq!(overview.total_queues, 5);
45//! assert_eq!(overview.overall_error_rate, 0.0125);
46//! assert_eq!(overview.system_health.status, "healthy");
47//! ```
48//!
49//! ## Statistics Queries
50//!
51//! ```rust
52//! use hammerwork_web::api::stats::{StatsQuery, TimeRange};
53//! use chrono::{Utc, Duration};
54//!
55//! let time_range = TimeRange {
56//!     start: Utc::now() - Duration::hours(24),
57//!     end: Utc::now(),
58//! };
59//!
60//! let query = StatsQuery {
61//!     time_range: Some(time_range),
62//!     queues: Some(vec!["email".to_string(), "notifications".to_string()]),
63//!     granularity: Some("hour".to_string()),
64//!     hours: None,
65//! };
66//!
67//! assert!(query.time_range.is_some());
68//! assert_eq!(query.queues.as_ref().unwrap().len(), 2);
69//! assert_eq!(query.granularity, Some("hour".to_string()));
70//! ```
71//!
72//! ## System Alerts
73//!
74//! ```rust
75//! use hammerwork_web::api::stats::SystemAlert;
76//! use chrono::Utc;
77//!
78//! let alert = SystemAlert {
79//!     severity: "warning".to_string(),
80//!     message: "Queue backlog detected".to_string(),
81//!     queue: Some("image_processing".to_string()),
82//!     metric: Some("pending_count".to_string()),
83//!     value: Some(1500.0),
84//!     threshold: Some(1000.0),
85//!     timestamp: Utc::now(),
86//! };
87//!
88//! assert_eq!(alert.severity, "warning");
89//! assert_eq!(alert.queue, Some("image_processing".to_string()));
90//! assert_eq!(alert.value, Some(1500.0));
91//! ```
92//!
93//! ## Performance Metrics
94//!
95//! ```rust
96//! use hammerwork_web::api::stats::PerformanceMetrics;
97//!
98//! let metrics = PerformanceMetrics {
99//!     database_response_time_ms: Some(5.2),
100//!     average_queue_depth: 15.5,
101//!     jobs_per_second: 8.3,
102//!     memory_usage_mb: Some(512.0),
103//!     cpu_usage_percent: Some(45.2),
104//!     active_workers: None, // not tracked: there is no worker registry
105//!     worker_utilization: None,
106//! };
107//!
108//! assert_eq!(metrics.database_response_time_ms, Some(5.2));
109//! assert_eq!(metrics.active_workers, None);
110//! ```
111
112use super::history::{ErrorGroup, JobHistory, MAX_BUCKETS, hour_floor};
113use super::{ApiResponse, error_reply, json_reply, with_shared};
114use hammerwork::queue::DatabaseQueue;
115use serde::{Deserialize, Serialize};
116use std::collections::HashMap;
117use std::sync::Arc;
118use warp::http::StatusCode;
119use warp::{Filter, Reply};
120
121/// System overview statistics
122#[derive(Debug, Serialize)]
123pub struct SystemOverview {
124    pub total_queues: u32,
125    pub total_jobs: u64,
126    pub pending_jobs: u64,
127    pub running_jobs: u64,
128    pub completed_jobs: u64,
129    pub failed_jobs: u64,
130    pub dead_jobs: u64,
131    pub overall_throughput: f64,
132    pub overall_error_rate: f64,
133    pub avg_processing_time_ms: f64,
134    pub system_health: SystemHealth,
135    pub uptime_seconds: u64,
136    pub last_updated: chrono::DateTime<chrono::Utc>,
137}
138
139/// System health status
140#[derive(Debug, Serialize)]
141pub struct SystemHealth {
142    pub status: String, // "healthy", "degraded", "critical"
143    pub database_healthy: bool,
144    pub high_error_rate: bool,
145    pub queue_backlog: bool,
146    pub slow_processing: bool,
147    pub alerts: Vec<SystemAlert>,
148}
149
150/// System alert
151#[derive(Debug, Serialize)]
152pub struct SystemAlert {
153    pub severity: String, // "info", "warning", "error", "critical"
154    pub message: String,
155    pub queue: Option<String>,
156    pub metric: Option<String>,
157    pub value: Option<f64>,
158    pub threshold: Option<f64>,
159    pub timestamp: chrono::DateTime<chrono::Utc>,
160}
161
162/// Detailed statistics for monitoring
163#[derive(Debug, Serialize)]
164pub struct DetailedStats {
165    pub overview: SystemOverview,
166    pub queue_stats: Vec<QueueStats>,
167    pub hourly_trends: Vec<HourlyTrend>,
168    pub error_patterns: Vec<ErrorPattern>,
169    pub performance_metrics: PerformanceMetrics,
170}
171
172/// Queue statistics
173#[derive(Debug, Serialize)]
174pub struct QueueStats {
175    pub name: String,
176    pub pending: u64,
177    pub running: u64,
178    pub completed_total: u64,
179    pub failed_total: u64,
180    pub dead_total: u64,
181    pub throughput_per_minute: f64,
182    pub avg_processing_time_ms: f64,
183    pub error_rate: f64,
184    pub oldest_pending_age_seconds: Option<u64>,
185    pub priority_distribution: HashMap<String, f32>,
186}
187
188/// Hourly trend data
189#[derive(Debug, Serialize)]
190pub struct HourlyTrend {
191    pub hour: chrono::DateTime<chrono::Utc>,
192    pub completed: u64,
193    pub failed: u64,
194    pub throughput: f64,
195    /// Mean run time of jobs completed in the hour; `null` when none completed.
196    pub avg_processing_time_ms: Option<f64>,
197    pub error_rate: f64,
198}
199
200/// Error pattern analysis
201#[derive(Debug, Serialize)]
202pub struct ErrorPattern {
203    pub error_type: String,
204    pub count: u64,
205    pub percentage: f64,
206    pub sample_message: String,
207    pub first_seen: chrono::DateTime<chrono::Utc>,
208    pub last_seen: chrono::DateTime<chrono::Utc>,
209    pub affected_queues: Vec<String>,
210}
211
212/// Performance metrics
213#[derive(Debug, Serialize)]
214pub struct PerformanceMetrics {
215    /// Measured time to fetch the queue statistics for this response.
216    pub database_response_time_ms: Option<f64>,
217    pub average_queue_depth: f64,
218    pub jobs_per_second: f64,
219    pub memory_usage_mb: Option<f64>,
220    pub cpu_usage_percent: Option<f64>,
221    /// Always `null`: Hammerwork has no worker registry, so worker counts are unknown.
222    pub active_workers: Option<u32>,
223    /// Always `null`, see `active_workers`.
224    pub worker_utilization: Option<f64>,
225}
226
227/// Time range for statistics queries
228#[derive(Debug, Deserialize)]
229pub struct TimeRange {
230    pub start: chrono::DateTime<chrono::Utc>,
231    pub end: chrono::DateTime<chrono::Utc>,
232}
233
234/// Statistics query parameters.
235///
236/// In a query string, `time_range` is `START,END` (two RFC 3339 timestamps, as in
237/// `time_range=2024-01-01T00:00:00Z,2024-01-02T00:00:00Z`) and `queues` is a
238/// comma-separated list (`queues=email,reports`). A JSON body form (an object with
239/// `start` and `end`, and an array of names) is accepted as well.
240#[derive(Debug, Deserialize)]
241pub struct StatsQuery {
242    #[serde(default, deserialize_with = "de_time_range")]
243    pub time_range: Option<TimeRange>,
244    /// Only these queues are reported; all queues when absent or empty.
245    #[serde(default, deserialize_with = "de_queues")]
246    pub queues: Option<Vec<String>>,
247    /// Size of the trend buckets: `hour` (the default), `day` (UTC days) or `week`
248    /// (UTC weeks starting on Monday).
249    pub granularity: Option<String>,
250    /// The last N hours (alternative to `time_range`, which a query string cannot carry).
251    pub hours: Option<u32>,
252}
253
254/// Deserializes `queues` from a comma-separated string or a list of names.
255fn de_queues<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
256where
257    D: serde::Deserializer<'de>,
258{
259    #[derive(Deserialize)]
260    #[serde(untagged)]
261    enum Raw {
262        List(Vec<String>),
263        Csv(String),
264    }
265    Ok(Option::<Raw>::deserialize(deserializer)?
266        .map(|raw| match raw {
267            Raw::List(names) => names,
268            Raw::Csv(csv) => csv.split(',').map(str::to_string).collect(),
269        })
270        .map(|names| {
271            names
272                .into_iter()
273                .map(|name| name.trim().to_string())
274                .filter(|name| !name.is_empty())
275                .collect()
276        }))
277}
278
279/// Deserializes `time_range` from `START,END` or an object with `start` and `end`.
280fn de_time_range<'de, D>(deserializer: D) -> Result<Option<TimeRange>, D::Error>
281where
282    D: serde::Deserializer<'de>,
283{
284    use serde::de::Error;
285
286    #[derive(Deserialize)]
287    #[serde(untagged)]
288    enum Raw {
289        Range(TimeRange),
290        Pair(String),
291    }
292    match Option::<Raw>::deserialize(deserializer)? {
293        None => Ok(None),
294        Some(Raw::Range(range)) => Ok(Some(range)),
295        Some(Raw::Pair(pair)) => {
296            let parse = |text: &str| {
297                chrono::DateTime::parse_from_rfc3339(text.trim())
298                    .map(|time| time.with_timezone(&chrono::Utc))
299                    .map_err(|e| D::Error::custom(format!("invalid time_range `{text}`: {e}")))
300            };
301            let (start, end) = pair.split_once(',').ok_or_else(|| {
302                D::Error::custom("time_range must be `START,END` (RFC 3339 timestamps)")
303            })?;
304            Ok(Some(TimeRange {
305                start: parse(start)?,
306                end: parse(end)?,
307            }))
308        }
309    }
310}
311
312/// The trend bucket size of a request.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314enum Granularity {
315    Hour,
316    Day,
317    Week,
318}
319
320impl Granularity {
321    fn parse(name: Option<&str>) -> Result<Self, String> {
322        match name.map(|name| name.trim().to_lowercase()).as_deref() {
323            None | Some("") | Some("hour") => Ok(Self::Hour),
324            Some("day") => Ok(Self::Day),
325            Some("week") => Ok(Self::Week),
326            Some(other) => Err(format!(
327                "Invalid granularity '{other}'. Valid options: hour, day, week"
328            )),
329        }
330    }
331
332    /// The start of the bucket containing `hour`.
333    fn bucket_start(self, hour: chrono::DateTime<chrono::Utc>) -> chrono::DateTime<chrono::Utc> {
334        use chrono::{Datelike, TimeZone};
335        let day = hour.date_naive();
336        let day = match self {
337            Self::Hour => return hour,
338            Self::Day => day,
339            Self::Week => {
340                day - chrono::Duration::days(i64::from(day.weekday().num_days_from_monday()))
341            }
342        };
343        chrono::Utc.from_utc_datetime(&day.and_hms_opt(0, 0, 0).unwrap_or_default())
344    }
345}
346
347/// Merges hourly trends (oldest first) into `granularity` buckets. Throughput is jobs per
348/// second over the hours merged; the mean run time is weighted by the completed jobs.
349fn roll_up(trends: Vec<HourlyTrend>, granularity: Granularity) -> Vec<HourlyTrend> {
350    if granularity == Granularity::Hour {
351        return trends;
352    }
353    struct Acc {
354        hour: chrono::DateTime<chrono::Utc>,
355        hours: u64,
356        completed: u64,
357        failed: u64,
358        processing_ms: f64,
359    }
360    let mut buckets: Vec<Acc> = Vec::new();
361    for trend in trends {
362        let start = granularity.bucket_start(trend.hour);
363        if buckets.last().is_none_or(|bucket| bucket.hour != start) {
364            buckets.push(Acc {
365                hour: start,
366                hours: 0,
367                completed: 0,
368                failed: 0,
369                processing_ms: 0.0,
370            });
371        }
372        if let Some(bucket) = buckets.last_mut() {
373            bucket.hours += 1;
374            bucket.completed += trend.completed;
375            bucket.failed += trend.failed;
376            bucket.processing_ms +=
377                trend.avg_processing_time_ms.unwrap_or(0.0) * trend.completed as f64;
378        }
379    }
380    buckets
381        .into_iter()
382        .map(|bucket| {
383            let total = bucket.completed + bucket.failed;
384            HourlyTrend {
385                hour: bucket.hour,
386                completed: bucket.completed,
387                failed: bucket.failed,
388                throughput: total as f64 / (bucket.hours * 3600) as f64,
389                avg_processing_time_ms: (bucket.completed > 0)
390                    .then(|| bucket.processing_ms / bucket.completed as f64),
391                error_rate: if total > 0 {
392                    bucket.failed as f64 / total as f64
393                } else {
394                    0.0
395                },
396            }
397        })
398        .collect()
399}
400
401/// Whether `queue_name` is selected by the request's `queues` filter.
402fn queue_selected(queues: &Option<Vec<String>>, queue_name: &str) -> bool {
403    match queues {
404        Some(names) if !names.is_empty() => names.iter().any(|name| name == queue_name),
405        _ => true,
406    }
407}
408
409/// Create statistics routes
410pub fn routes<T>(
411    queue: Arc<T>,
412    system_state: Arc<tokio::sync::RwLock<crate::api::system::SystemState>>,
413) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone
414where
415    T: JobHistory + 'static,
416{
417    let queue_filter = with_shared(queue);
418    let state_filter = with_shared(system_state);
419
420    let overview = warp::path("stats")
421        .and(warp::path("overview"))
422        .and(warp::path::end())
423        .and(warp::get())
424        .and(queue_filter.clone())
425        .and(state_filter.clone())
426        .and_then(overview_handler);
427
428    let detailed = warp::path("stats")
429        .and(warp::path("detailed"))
430        .and(warp::path::end())
431        .and(warp::get())
432        .and(queue_filter.clone())
433        .and(state_filter.clone())
434        .and(warp::query::<StatsQuery>())
435        .and_then(detailed_stats_handler);
436
437    let trends = warp::path("stats")
438        .and(warp::path("trends"))
439        .and(warp::path::end())
440        .and(warp::get())
441        .and(queue_filter.clone())
442        .and(warp::query::<StatsQuery>())
443        .and_then(trends_handler);
444
445    let health = warp::path("stats")
446        .and(warp::path("health"))
447        .and(warp::path::end())
448        .and(warp::get())
449        .and(queue_filter)
450        .and_then(health_handler);
451
452    overview.or(detailed).or(trends).or(health)
453}
454
455/// Handler for system overview statistics
456async fn overview_handler<T>(
457    queue: Arc<T>,
458    system_state: Arc<tokio::sync::RwLock<crate::api::system::SystemState>>,
459) -> Result<impl Reply, warp::Rejection>
460where
461    T: DatabaseQueue + Send + Sync,
462{
463    match queue.get_all_queue_stats().await {
464        Ok(all_stats) => {
465            let uptime_seconds =
466                u64::try_from(system_state.read().await.uptime_seconds()).unwrap_or(0);
467            let overview = generate_overview_from_stats(&all_stats, uptime_seconds);
468            Ok(json_reply(&ApiResponse::success(overview)))
469        }
470        Err(e) => Ok(error_reply(
471            StatusCode::INTERNAL_SERVER_ERROR,
472            format!("Failed to get statistics: {}", e),
473        )),
474    }
475}
476
477/// Handler for detailed statistics
478async fn detailed_stats_handler<T>(
479    queue: Arc<T>,
480    system_state: Arc<tokio::sync::RwLock<crate::api::system::SystemState>>,
481    query: StatsQuery,
482) -> Result<impl Reply, warp::Rejection>
483where
484    T: JobHistory,
485{
486    let now = chrono::Utc::now();
487    let (start, end) = match resolve_range(&query, now) {
488        Ok(range) => range,
489        Err(message) => return Ok(error_reply(StatusCode::BAD_REQUEST, message)),
490    };
491    let granularity = match Granularity::parse(query.granularity.as_deref()) {
492        Ok(granularity) => granularity,
493        Err(message) => return Ok(error_reply(StatusCode::BAD_REQUEST, message)),
494    };
495
496    let db_started = std::time::Instant::now();
497    match queue.get_all_queue_stats().await {
498        Ok(mut all_stats) => {
499            all_stats.retain(|stats| queue_selected(&query.queues, &stats.queue_name));
500            let db_ms = db_started.elapsed().as_secs_f64() * 1000.0;
501            // Convert hammerwork stats to our API format
502            let mut queue_stats: Vec<QueueStats> = Vec::new();
503            for stats in all_stats.iter() {
504                // Calculate oldest pending age seconds
505                let oldest_pending_age_seconds = try_api!(
506                    calculate_oldest_pending_age(&queue, &stats.queue_name).await,
507                    "Failed to get oldest pending job age"
508                );
509
510                // Get priority distribution from priority stats
511                let priority_distribution = try_api!(
512                    get_priority_distribution(&queue, &stats.queue_name).await,
513                    "Failed to get priority distribution"
514                );
515
516                queue_stats.push(QueueStats {
517                    name: stats.queue_name.clone(),
518                    pending: stats.pending_count,
519                    running: stats.running_count,
520                    completed_total: stats.completed_count,
521                    failed_total: super::failed_job_count(stats),
522                    dead_total: stats.dead_count,
523                    throughput_per_minute: stats.statistics.throughput_per_minute,
524                    avg_processing_time_ms: stats.statistics.avg_processing_time_ms,
525                    error_rate: stats.statistics.error_rate,
526                    oldest_pending_age_seconds,
527                    priority_distribution,
528                });
529            }
530
531            let hourly_trends = try_api!(
532                compute_trends(&*queue, start, end, &query.queues, granularity).await,
533                "Failed to compute hourly trends"
534            );
535            let error_patterns = try_api!(
536                generate_error_patterns(&*queue, start, &query.queues).await,
537                "Failed to compute error patterns"
538            );
539            let performance_metrics = calculate_performance_metrics(&all_stats, db_ms);
540
541            let uptime_seconds =
542                u64::try_from(system_state.read().await.uptime_seconds()).unwrap_or(0);
543            let overview = generate_overview_from_stats(&all_stats, uptime_seconds);
544
545            let detailed = DetailedStats {
546                overview,
547                queue_stats,
548                hourly_trends,
549                error_patterns,
550                performance_metrics,
551            };
552
553            Ok(json_reply(&ApiResponse::success(detailed)))
554        }
555        Err(e) => Ok(error_reply(
556            StatusCode::INTERNAL_SERVER_ERROR,
557            format!("Failed to get detailed statistics: {}", e),
558        )),
559    }
560}
561
562/// The `[start, end)` window of a stats request: `time_range` when given (validated),
563/// otherwise the last 24 hours (23 whole hours plus the current one).
564fn resolve_range(
565    query: &StatsQuery,
566    now: chrono::DateTime<chrono::Utc>,
567) -> Result<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>), String> {
568    if let Some(hours) = query.hours {
569        if hours == 0 || i64::from(hours) > MAX_BUCKETS {
570            return Err(format!("hours must be between 1 and {}", MAX_BUCKETS));
571        }
572        return Ok((
573            hour_floor(now) - chrono::Duration::hours(i64::from(hours) - 1),
574            now,
575        ));
576    }
577    match &query.time_range {
578        Some(range) => {
579            if range.start >= range.end {
580                Err("time_range.start must be before time_range.end".to_string())
581            } else if range.end - range.start > chrono::Duration::hours(MAX_BUCKETS) {
582                Err(format!("time_range may span at most {} hours", MAX_BUCKETS))
583            } else {
584                Ok((range.start, range.end))
585            }
586        }
587        None => Ok((hour_floor(now) - chrono::Duration::hours(23), now)),
588    }
589}
590
591/// Handler for trend analysis: hourly completed/failed counts from the database.
592async fn trends_handler<T>(queue: Arc<T>, query: StatsQuery) -> Result<impl Reply, warp::Rejection>
593where
594    T: JobHistory,
595{
596    let (start, end) = match resolve_range(&query, chrono::Utc::now()) {
597        Ok(range) => range,
598        Err(message) => return Ok(error_reply(StatusCode::BAD_REQUEST, message)),
599    };
600    let granularity = match Granularity::parse(query.granularity.as_deref()) {
601        Ok(granularity) => granularity,
602        Err(message) => return Ok(error_reply(StatusCode::BAD_REQUEST, message)),
603    };
604    let trends = try_api!(
605        compute_trends(&*queue, start, end, &query.queues, granularity).await,
606        "Failed to compute hourly trends"
607    );
608    Ok(json_reply(&ApiResponse::success(trends)))
609}
610
611/// Handler for system health check
612async fn health_handler<T>(queue: Arc<T>) -> Result<impl Reply, warp::Rejection>
613where
614    T: DatabaseQueue + Send + Sync,
615{
616    match queue.get_all_queue_stats().await {
617        Ok(all_stats) => {
618            let health = assess_system_health(&all_stats);
619            Ok(json_reply(&ApiResponse::success(health)))
620        }
621        Err(e) => {
622            let health = SystemHealth {
623                status: "critical".to_string(),
624                database_healthy: false,
625                high_error_rate: false,
626                queue_backlog: false,
627                slow_processing: false,
628                alerts: vec![SystemAlert {
629                    severity: "critical".to_string(),
630                    message: format!("Database connection failed: {}", e),
631                    queue: None,
632                    metric: Some("database_connectivity".to_string()),
633                    value: None,
634                    threshold: None,
635                    timestamp: chrono::Utc::now(),
636                }],
637            };
638            Ok(json_reply(&ApiResponse::success(health)))
639        }
640    }
641}
642
643/// Assess overall system health based on queue statistics
644fn assess_system_health(stats: &[hammerwork::stats::QueueStats]) -> SystemHealth {
645    let mut alerts = Vec::new();
646    let mut high_error_rate = false;
647    let mut queue_backlog = false;
648    let mut slow_processing = false;
649
650    for stat in stats {
651        // Check error rate
652        if stat.statistics.error_rate > 0.1 {
653            // > 10% error rate
654            high_error_rate = true;
655            alerts.push(SystemAlert {
656                severity: "warning".to_string(),
657                message: format!("High error rate in queue '{}'", stat.queue_name),
658                queue: Some(stat.queue_name.clone()),
659                metric: Some("error_rate".to_string()),
660                value: Some(stat.statistics.error_rate),
661                threshold: Some(0.1),
662                timestamp: chrono::Utc::now(),
663            });
664        }
665
666        // Check queue backlog
667        if stat.pending_count > 1000 {
668            queue_backlog = true;
669            alerts.push(SystemAlert {
670                severity: "warning".to_string(),
671                message: format!("Large backlog in queue '{}'", stat.queue_name),
672                queue: Some(stat.queue_name.clone()),
673                metric: Some("pending_count".to_string()),
674                value: Some(stat.pending_count as f64),
675                threshold: Some(1000.0),
676                timestamp: chrono::Utc::now(),
677            });
678        }
679
680        // Check processing time
681        if stat.statistics.avg_processing_time_ms > 30000.0 {
682            // > 30 seconds
683            slow_processing = true;
684            alerts.push(SystemAlert {
685                severity: "info".to_string(),
686                message: format!("Slow processing in queue '{}'", stat.queue_name),
687                queue: Some(stat.queue_name.clone()),
688                metric: Some("avg_processing_time_ms".to_string()),
689                value: Some(stat.statistics.avg_processing_time_ms),
690                threshold: Some(30000.0),
691                timestamp: chrono::Utc::now(),
692            });
693        }
694    }
695
696    let status = if alerts.iter().any(|a| a.severity == "critical") {
697        "critical"
698    } else if alerts.iter().any(|a| a.severity == "warning") {
699        "degraded"
700    } else {
701        "healthy"
702    };
703
704    SystemHealth {
705        status: status.to_string(),
706        database_healthy: true, // If we got here, DB is accessible
707        high_error_rate,
708        queue_backlog,
709        slow_processing,
710        alerts,
711    }
712}
713
714/// Generate system overview from queue statistics
715fn generate_overview_from_stats(
716    stats: &[hammerwork::stats::QueueStats],
717    uptime_seconds: u64,
718) -> SystemOverview {
719    let mut total_pending = 0;
720    let mut total_running = 0;
721    let mut total_completed = 0;
722    let mut total_failed = 0;
723    let mut total_dead = 0;
724    let mut total_throughput = 0.0;
725    let mut total_processing_time = 0.0;
726    let queue_count = stats.len();
727
728    for stat in stats {
729        total_pending += stat.pending_count;
730        total_running += stat.running_count;
731        total_completed += stat.completed_count;
732        total_failed += super::failed_job_count(stat);
733        total_dead += stat.dead_count;
734        total_throughput += stat.statistics.throughput_per_minute;
735        total_processing_time += stat.statistics.avg_processing_time_ms;
736    }
737
738    let avg_processing_time = if queue_count > 0 {
739        total_processing_time / queue_count as f64
740    } else {
741        0.0
742    };
743
744    let total_jobs = total_pending + total_running + total_completed + total_failed;
745    let overall_error_rate = if total_jobs > 0 {
746        total_failed as f64 / total_jobs as f64
747    } else {
748        0.0
749    };
750
751    let health = assess_system_health(stats);
752
753    SystemOverview {
754        total_queues: queue_count as u32,
755        total_jobs,
756        pending_jobs: total_pending,
757        running_jobs: total_running,
758        completed_jobs: total_completed,
759        failed_jobs: total_failed,
760        dead_jobs: total_dead,
761        overall_throughput: total_throughput,
762        overall_error_rate,
763        avg_processing_time_ms: avg_processing_time,
764        system_health: health,
765        uptime_seconds,
766        last_updated: chrono::Utc::now(),
767    }
768}
769
770/// Calculate the oldest pending job age in seconds for a queue
771async fn calculate_oldest_pending_age<T>(
772    queue: &Arc<T>,
773    queue_name: &str,
774) -> hammerwork::Result<Option<u64>>
775where
776    T: DatabaseQueue + Send + Sync,
777{
778    // Get ready jobs (pending jobs) and find the oldest
779    let jobs = queue.get_ready_jobs(queue_name, 100).await?;
780    let now = chrono::Utc::now();
781    Ok(jobs
782        .iter()
783        .filter(|job| matches!(job.status, hammerwork::job::JobStatus::Pending))
784        .map(|job| age_seconds(now, job.created_at))
785        .max())
786}
787
788/// Age in whole seconds between `created_at` and `now`, clamped to zero when
789/// `created_at` is in the future (clock skew) instead of wrapping to ~1.8e19.
790fn age_seconds(
791    now: chrono::DateTime<chrono::Utc>,
792    created_at: chrono::DateTime<chrono::Utc>,
793) -> u64 {
794    u64::try_from((now - created_at).num_seconds()).unwrap_or(0)
795}
796
797/// Get priority distribution from priority stats for a queue
798async fn get_priority_distribution<T>(
799    queue: &Arc<T>,
800    queue_name: &str,
801) -> hammerwork::Result<HashMap<String, f32>>
802where
803    T: DatabaseQueue + Send + Sync,
804{
805    let priority_stats = queue.get_priority_stats(queue_name).await?;
806    Ok(priority_stats
807        .priority_distribution
808        .into_iter()
809        .map(|(priority, percentage)| (priority.to_string(), percentage))
810        .collect())
811}
812
813/// Hourly trends for `[start, end)` computed from the database (zero-filled).
814async fn compute_trends<T>(
815    queue: &T,
816    start: chrono::DateTime<chrono::Utc>,
817    end: chrono::DateTime<chrono::Utc>,
818    queues: &Option<Vec<String>>,
819    granularity: Granularity,
820) -> hammerwork::Result<Vec<HourlyTrend>>
821where
822    T: JobHistory,
823{
824    let buckets = match queues {
825        Some(names) if !names.is_empty() => {
826            // The selected queues' activity, summed hour by hour.
827            let mut merged: Vec<super::history::HourBucket> = Vec::new();
828            for name in names {
829                let queue_buckets = queue.hourly_activity(Some(name), start, end).await?;
830                if merged.is_empty() {
831                    merged = queue_buckets;
832                    continue;
833                }
834                for (total, bucket) in merged.iter_mut().zip(queue_buckets) {
835                    let done = total.completed + bucket.completed;
836                    total.avg_processing_time_ms = if done == 0 {
837                        None
838                    } else {
839                        Some(
840                            (total.avg_processing_time_ms.unwrap_or(0.0) * total.completed as f64
841                                + bucket.avg_processing_time_ms.unwrap_or(0.0)
842                                    * bucket.completed as f64)
843                                / done as f64,
844                        )
845                    };
846                    total.completed = done;
847                    total.failed += bucket.failed;
848                }
849            }
850            merged
851        }
852        _ => queue.hourly_activity(None, start, end).await?,
853    };
854    Ok(roll_up(
855        buckets.into_iter().map(trend_from_bucket).collect(),
856        granularity,
857    ))
858}
859
860fn trend_from_bucket(bucket: super::history::HourBucket) -> HourlyTrend {
861    let total = bucket.completed + bucket.failed;
862    HourlyTrend {
863        hour: bucket.hour,
864        completed: bucket.completed,
865        failed: bucket.failed,
866        throughput: total as f64 / 3600.0,
867        avg_processing_time_ms: bucket.avg_processing_time_ms,
868        error_rate: if total > 0 {
869            bucket.failed as f64 / total as f64
870        } else {
871            0.0
872        },
873    }
874}
875
876/// Error patterns of failures since `since`, grouped by error type.
877///
878/// Built from the 500 most frequent distinct error messages, so counts and percentages
879/// are exact unless the failure set has more distinct messages than that.
880async fn generate_error_patterns<T>(
881    queue: &T,
882    since: chrono::DateTime<chrono::Utc>,
883    queues: &Option<Vec<String>>,
884) -> hammerwork::Result<Vec<ErrorPattern>>
885where
886    T: JobHistory,
887{
888    let mut groups = queue.error_groups(since, 500).await?;
889    groups.retain(|group| queue_selected(queues, &group.queue_name));
890    Ok(group_error_patterns(groups))
891}
892
893/// Folds per-(queue, message) groups into one pattern per error type with real
894/// first/last-seen times and the set of affected queues.
895fn group_error_patterns(groups: Vec<ErrorGroup>) -> Vec<ErrorPattern> {
896    use std::collections::{BTreeMap, BTreeSet};
897
898    struct Agg {
899        count: u64,
900        sample: (u64, String),
901        first_seen: chrono::DateTime<chrono::Utc>,
902        last_seen: chrono::DateTime<chrono::Utc>,
903        queues: BTreeSet<String>,
904    }
905
906    let total: u64 = groups.iter().map(|g| g.count).sum();
907    let mut by_type: BTreeMap<String, Agg> = BTreeMap::new();
908    for group in groups {
909        let error_type = extract_error_type(&group.message);
910        let agg = by_type.entry(error_type).or_insert_with(|| Agg {
911            count: 0,
912            sample: (0, group.message.clone()),
913            first_seen: group.first_seen,
914            last_seen: group.last_seen,
915            queues: BTreeSet::new(),
916        });
917        agg.count += group.count;
918        if group.count > agg.sample.0 {
919            agg.sample = (group.count, group.message.clone());
920        }
921        agg.first_seen = agg.first_seen.min(group.first_seen);
922        agg.last_seen = agg.last_seen.max(group.last_seen);
923        agg.queues.insert(group.queue_name);
924    }
925
926    let mut patterns: Vec<ErrorPattern> = by_type
927        .into_iter()
928        .map(|(error_type, agg)| ErrorPattern {
929            error_type,
930            count: agg.count,
931            percentage: if total > 0 {
932                agg.count as f64 / total as f64 * 100.0
933            } else {
934                0.0
935            },
936            sample_message: agg.sample.1,
937            first_seen: agg.first_seen,
938            last_seen: agg.last_seen,
939            affected_queues: agg.queues.into_iter().collect(),
940        })
941        .collect();
942    patterns.sort_by(|a, b| {
943        b.count
944            .cmp(&a.count)
945            .then_with(|| a.error_type.cmp(&b.error_type))
946    });
947    patterns
948}
949
950/// Calculate performance metrics from queue statistics.
951///
952/// `database_response_time_ms` is the measured time of the statistics query. Worker
953/// counts and utilization are `None`: running jobs are not workers, and there is no
954/// worker registry to count them from.
955fn calculate_performance_metrics(
956    all_stats: &[hammerwork::stats::QueueStats],
957    database_response_time_ms: f64,
958) -> PerformanceMetrics {
959    let total_throughput = all_stats
960        .iter()
961        .map(|s| s.statistics.throughput_per_minute)
962        .sum::<f64>();
963
964    let average_queue_depth = if !all_stats.is_empty() {
965        all_stats
966            .iter()
967            .map(|s| s.pending_count as f64)
968            .sum::<f64>()
969            / all_stats.len() as f64
970    } else {
971        0.0
972    };
973
974    PerformanceMetrics {
975        database_response_time_ms: Some(database_response_time_ms),
976        average_queue_depth,
977        jobs_per_second: total_throughput / 60.0, // Convert from per minute to per second
978        memory_usage_mb: None,
979        cpu_usage_percent: None,
980        active_workers: None,
981        worker_utilization: None,
982    }
983}
984
985/// Extract error type from error message for grouping
986fn extract_error_type(error_msg: &str) -> String {
987    // Simple error classification logic
988    if error_msg.contains("timeout") || error_msg.contains("Timeout") {
989        "Timeout Error".to_string()
990    } else if error_msg.contains("connection") || error_msg.contains("Connection") {
991        "Connection Error".to_string()
992    } else if error_msg.contains("parse")
993        || error_msg.contains("Parse")
994        || error_msg.contains("invalid")
995    {
996        "Parse Error".to_string()
997    } else if error_msg.contains("permission")
998        || error_msg.contains("Permission")
999        || error_msg.contains("forbidden")
1000    {
1001        "Permission Error".to_string()
1002    } else if error_msg.contains("not found") || error_msg.contains("Not Found") {
1003        "Not Found Error".to_string()
1004    } else {
1005        // Use first word of error message as type
1006        error_msg
1007            .split_whitespace()
1008            .next()
1009            .map(|s| format!("{} Error", s))
1010            .unwrap_or_else(|| "Unknown Error".to_string())
1011    }
1012}
1013
1014#[cfg(test)]
1015mod tests {
1016    use super::*;
1017    use crate::api::test_support::{body_json, unreachable_queue};
1018    use chrono::TimeZone;
1019
1020    #[test]
1021    fn test_overview_counts_failed_dead_and_timed_out_jobs_as_failed() {
1022        let mut stats = hammerwork::stats::QueueStats {
1023            queue_name: "q".to_string(),
1024            pending_count: 1,
1025            running_count: 0,
1026            dead_count: 2,
1027            timed_out_count: 3,
1028            completed_count: 4,
1029            statistics: Default::default(),
1030        };
1031        // Jobs in status `Failed` (a failed dependency, a fail-fast batch, `fail_job`)
1032        stats.statistics.failed = 5;
1033        let overview = generate_overview_from_stats(&[stats], 0);
1034        assert_eq!(overview.failed_jobs, 5 + 2 + 3);
1035        assert_eq!(overview.dead_jobs, 2);
1036        assert_eq!(overview.total_jobs, 1 + 4 + 10);
1037        assert!((overview.overall_error_rate - 10.0 / 15.0).abs() < 1e-9);
1038    }
1039
1040    fn state() -> Arc<tokio::sync::RwLock<crate::api::system::SystemState>> {
1041        Arc::new(tokio::sync::RwLock::new(
1042            crate::api::system::SystemState::new(
1043                crate::DashboardConfig::default(),
1044                "PostgreSQL".to_string(),
1045                1,
1046            ),
1047        ))
1048    }
1049
1050    fn empty_query() -> StatsQuery {
1051        serde_json::from_value(serde_json::json!({})).unwrap()
1052    }
1053
1054    #[tokio::test]
1055    async fn test_detailed_stats_returns_500_when_database_is_down() {
1056        let response = detailed_stats_handler(unreachable_queue(), state(), empty_query())
1057            .await
1058            .unwrap()
1059            .into_response();
1060        let (status, body) = body_json(response).await;
1061        assert_eq!(status, 500);
1062        assert_eq!(body["success"], false);
1063    }
1064
1065    #[tokio::test]
1066    async fn test_trends_returns_500_when_database_is_down() {
1067        let response = trends_handler(unreachable_queue(), empty_query())
1068            .await
1069            .unwrap()
1070            .into_response();
1071        let (status, body) = body_json(response).await;
1072        assert_eq!(status, 500);
1073        assert_eq!(body["success"], false);
1074    }
1075
1076    #[tokio::test]
1077    async fn test_trends_rejects_invalid_time_range() {
1078        let inverted: StatsQuery = serde_json::from_value(serde_json::json!({
1079            "time_range": {"start": "2024-01-02T00:00:00Z", "end": "2024-01-01T00:00:00Z"}
1080        }))
1081        .unwrap();
1082        let response = trends_handler(unreachable_queue(), inverted)
1083            .await
1084            .unwrap()
1085            .into_response();
1086        assert_eq!(body_json(response).await.0, 400);
1087
1088        let too_long: StatsQuery = serde_json::from_value(serde_json::json!({
1089            "time_range": {"start": "2020-01-01T00:00:00Z", "end": "2024-01-01T00:00:00Z"}
1090        }))
1091        .unwrap();
1092        let response = trends_handler(unreachable_queue(), too_long)
1093            .await
1094            .unwrap()
1095            .into_response();
1096        assert_eq!(body_json(response).await.0, 400);
1097    }
1098
1099    #[test]
1100    fn test_default_range_is_24_hourly_buckets() {
1101        let now = chrono::Utc::now();
1102        let (start, end) = resolve_range(&empty_query(), now).unwrap();
1103        assert_eq!(end, now);
1104        assert_eq!(crate::api::history::hour_range(start, end).len(), 24);
1105    }
1106
1107    #[test]
1108    fn test_trend_from_bucket_computes_rates_without_inventing_values() {
1109        let hour = hour_floor(chrono::Utc::now());
1110        let busy = trend_from_bucket(crate::api::history::HourBucket {
1111            hour,
1112            completed: 3,
1113            failed: 1,
1114            avg_processing_time_ms: Some(40.0),
1115        });
1116        assert_eq!(busy.error_rate, 0.25);
1117        assert_eq!(busy.throughput, 4.0 / 3600.0);
1118        let idle = trend_from_bucket(crate::api::history::HourBucket {
1119            hour,
1120            completed: 0,
1121            failed: 0,
1122            avg_processing_time_ms: None,
1123        });
1124        assert_eq!(idle.error_rate, 0.0);
1125        assert_eq!(idle.avg_processing_time_ms, None);
1126    }
1127
1128    #[test]
1129    fn test_group_error_patterns_uses_real_times_and_queues() {
1130        let now = chrono::Utc::now();
1131        let t = |h| now - chrono::Duration::hours(h);
1132        let group = |queue: &str, msg: &str, count, first, last| ErrorGroup {
1133            queue_name: queue.to_string(),
1134            message: msg.to_string(),
1135            count,
1136            first_seen: t(first),
1137            last_seen: t(last),
1138        };
1139        let patterns = group_error_patterns(vec![
1140            group("emails", "connection refused by host a", 6, 10, 2),
1141            group("reports", "connection reset", 2, 8, 1),
1142            group("emails", "request timeout", 2, 5, 3),
1143        ]);
1144        assert_eq!(patterns.len(), 2);
1145        let conn = &patterns[0];
1146        assert_eq!(conn.error_type, "Connection Error");
1147        assert_eq!(conn.count, 8);
1148        assert_eq!(conn.percentage, 80.0);
1149        assert_eq!(conn.sample_message, "connection refused by host a");
1150        assert_eq!(conn.affected_queues, vec!["emails", "reports"]);
1151        assert_eq!(conn.first_seen, t(10));
1152        assert_eq!(conn.last_seen, t(1));
1153        assert_eq!(patterns[1].affected_queues, vec!["emails"]);
1154        assert!(group_error_patterns(Vec::new()).is_empty());
1155    }
1156
1157    #[test]
1158    fn test_performance_metrics_report_unknowns_as_none() {
1159        let metrics = calculate_performance_metrics(&[], 3.5);
1160        assert_eq!(metrics.database_response_time_ms, Some(3.5));
1161        assert_eq!(metrics.active_workers, None);
1162        assert_eq!(metrics.worker_utilization, None);
1163        assert_eq!(metrics.memory_usage_mb, None);
1164    }
1165
1166    #[test]
1167    fn test_age_seconds_clamps_future_timestamps_to_zero() {
1168        let now = chrono::Utc::now();
1169        assert_eq!(age_seconds(now, now + chrono::Duration::seconds(30)), 0);
1170        assert_eq!(age_seconds(now, now - chrono::Duration::seconds(30)), 30);
1171    }
1172
1173    #[test]
1174    fn test_stats_query_deserialization() {
1175        let json = r#"{
1176            "time_range": {
1177                "start": "2024-01-01T00:00:00Z",
1178                "end": "2024-01-02T00:00:00Z"
1179            },
1180            "queues": ["email", "data-processing"],
1181            "granularity": "hour"
1182        }"#;
1183
1184        let query: StatsQuery = serde_json::from_str(json).unwrap();
1185        assert!(query.time_range.is_some());
1186        assert_eq!(query.queues.as_ref().unwrap().len(), 2);
1187        assert_eq!(query.granularity, Some("hour".to_string()));
1188    }
1189
1190    /// #103: `time_range` and `queues` decode from a real query string.
1191    #[tokio::test]
1192    async fn test_stats_query_decodes_from_a_query_string() {
1193        let decode = |query: &str| {
1194            let path = format!("/stats?{query}");
1195            async move {
1196                warp::test::request()
1197                    .path(&path)
1198                    .filter(&warp::query::<StatsQuery>())
1199                    .await
1200            }
1201        };
1202        let query = decode(
1203            "queues=email,%20reports,&granularity=day\
1204             &time_range=2024-01-01T00:00:00Z,2024-01-02T00:00:00Z",
1205        )
1206        .await
1207        .unwrap();
1208        assert_eq!(query.queues.unwrap(), vec!["email", "reports"]);
1209        assert_eq!(query.granularity.as_deref(), Some("day"));
1210        let range = query.time_range.unwrap();
1211        assert_eq!(range.start.to_rfc3339(), "2024-01-01T00:00:00+00:00");
1212        assert_eq!(range.end - range.start, chrono::Duration::days(1));
1213
1214        let bare = decode("hours=6").await.unwrap();
1215        assert!(bare.time_range.is_none() && bare.queues.is_none());
1216        assert!(decode("time_range=yesterday").await.is_err());
1217        assert!(decode("time_range=2024-01-01T00:00:00Z").await.is_err());
1218    }
1219
1220    #[tokio::test]
1221    async fn test_stats_routes_accept_query_string_filters() {
1222        let routes = routes(Arc::new(hammerwork::queue::test::TestQueue::new()), state());
1223        let status = |path: String| {
1224            let routes = routes.clone();
1225            async move {
1226                warp::test::request()
1227                    .path(&path)
1228                    .reply(&routes)
1229                    .await
1230                    .status()
1231                    .as_u16()
1232            }
1233        };
1234        let range = "time_range=2024-01-01T00:00:00Z,2024-01-02T00:00:00Z";
1235        assert_eq!(
1236            status(format!("/stats/trends?{range}&queues=a,b&granularity=week")).await,
1237            200
1238        );
1239        assert_eq!(
1240            status("/stats/trends?granularity=month".to_string()).await,
1241            400
1242        );
1243        assert_eq!(
1244            status("/stats/trends?time_range=2024-01-02T00:00:00Z,2024-01-01T00:00:00Z".into())
1245                .await,
1246            400
1247        );
1248    }
1249
1250    #[test]
1251    fn test_roll_up_merges_hours_into_days_and_weeks() {
1252        let at = |day: u32, hour: u32| {
1253            chrono::Utc
1254                .with_ymd_and_hms(2024, 1, day, hour, 0, 0)
1255                .unwrap()
1256        };
1257        let trend = |hour, completed, failed, avg| HourlyTrend {
1258            hour,
1259            completed,
1260            failed,
1261            throughput: 0.0,
1262            avg_processing_time_ms: avg,
1263            error_rate: 0.0,
1264        };
1265        // Sunday the 7th and Monday the 8th (UTC), three hours in all.
1266        let hours = || {
1267            vec![
1268                trend(at(7, 22), 1, 0, Some(100.0)),
1269                trend(at(7, 23), 3, 1, Some(200.0)),
1270                trend(at(8, 0), 0, 0, None),
1271            ]
1272        };
1273
1274        assert_eq!(roll_up(hours(), Granularity::Hour).len(), 3);
1275
1276        let days = roll_up(hours(), Granularity::Day);
1277        assert_eq!(days.len(), 2);
1278        assert_eq!(days[0].hour, at(7, 0));
1279        assert_eq!((days[0].completed, days[0].failed), (4, 1));
1280        assert!((days[0].avg_processing_time_ms.unwrap() - 175.0).abs() < 1e-9);
1281        assert!((days[0].throughput - 5.0 / 7200.0).abs() < 1e-12);
1282        assert!((days[0].error_rate - 0.2).abs() < 1e-12);
1283        assert_eq!(days[1].hour, at(8, 0));
1284        assert_eq!(days[1].avg_processing_time_ms, None);
1285
1286        let weeks = roll_up(hours(), Granularity::Week);
1287        assert_eq!(
1288            weeks.len(),
1289            2,
1290            "the Sunday and the Monday are in different weeks"
1291        );
1292        assert_eq!(weeks[0].hour, at(1, 0), "Monday the 1st");
1293        assert_eq!(weeks[1].hour, at(8, 0));
1294    }
1295
1296    #[test]
1297    fn test_granularity_and_queue_filters() {
1298        assert_eq!(Granularity::parse(None), Ok(Granularity::Hour));
1299        assert_eq!(Granularity::parse(Some(" DAY ")), Ok(Granularity::Day));
1300        assert!(Granularity::parse(Some("month")).is_err());
1301        let all: Option<Vec<String>> = None;
1302        let some = Some(vec!["a".to_string()]);
1303        assert!(queue_selected(&all, "x") && queue_selected(&Some(vec![]), "x"));
1304        assert!(queue_selected(&some, "a") && !queue_selected(&some, "b"));
1305    }
1306
1307    #[test]
1308    fn test_system_alert_serialization() {
1309        let alert = SystemAlert {
1310            severity: "warning".to_string(),
1311            message: "High error rate detected".to_string(),
1312            queue: Some("email".to_string()),
1313            metric: Some("error_rate".to_string()),
1314            value: Some(0.15),
1315            threshold: Some(0.1),
1316            timestamp: chrono::Utc::now(),
1317        };
1318
1319        let json = serde_json::to_string(&alert).unwrap();
1320        assert!(json.contains("warning"));
1321        assert!(json.contains("High error rate"));
1322    }
1323}