359 lines
11 KiB
Rust
359 lines
11 KiB
Rust
//! Analytics DTOs for API requests and responses
|
||
//!
|
||
//! These types represent the API-facing view of analytics data derived from
|
||
//! TimescaleDB continuous aggregates over entity history hypertables.
|
||
|
||
use chrono::{DateTime, Utc};
|
||
use serde::{Deserialize, Serialize};
|
||
use utoipa::{IntoParams, ToSchema};
|
||
|
||
use attune_common::repositories::analytics::{
|
||
AnalyticsTimeRange, EnforcementVolumeBucket, EventVolumeBucket, ExecutionStatusBucket,
|
||
ExecutionThroughputBucket, FailureRateSummary, WorkerStatusBucket,
|
||
};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Query parameters
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Common query parameters for analytics endpoints.
|
||
#[derive(Debug, Clone, Deserialize, IntoParams)]
|
||
pub struct AnalyticsQueryParams {
|
||
/// Start of time range (ISO 8601). Defaults to 24 hours ago.
|
||
#[param(example = "2026-02-25T00:00:00Z")]
|
||
pub since: Option<DateTime<Utc>>,
|
||
|
||
/// End of time range (ISO 8601). Defaults to now.
|
||
#[param(example = "2026-02-26T00:00:00Z")]
|
||
pub until: Option<DateTime<Utc>>,
|
||
|
||
/// Number of hours to look back from now (alternative to since/until).
|
||
/// Ignored if `since` is provided.
|
||
#[param(example = 24, minimum = 1, maximum = 8760)]
|
||
pub hours: Option<i64>,
|
||
}
|
||
|
||
impl AnalyticsQueryParams {
|
||
/// Convert to the repository-level time range.
|
||
pub fn to_time_range(&self) -> AnalyticsTimeRange {
|
||
match (&self.since, &self.until) {
|
||
(Some(since), Some(until)) => AnalyticsTimeRange {
|
||
since: *since,
|
||
until: *until,
|
||
},
|
||
(Some(since), None) => AnalyticsTimeRange {
|
||
since: *since,
|
||
until: Utc::now(),
|
||
},
|
||
(None, Some(until)) => {
|
||
let hours = self.hours.unwrap_or(24).clamp(1, 8760);
|
||
AnalyticsTimeRange {
|
||
since: *until - chrono::Duration::hours(hours),
|
||
until: *until,
|
||
}
|
||
}
|
||
(None, None) => {
|
||
let hours = self.hours.unwrap_or(24).clamp(1, 8760);
|
||
AnalyticsTimeRange::last_hours(hours)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Path parameter for filtering analytics by a specific entity ref.
|
||
#[derive(Debug, Clone, Deserialize, IntoParams)]
|
||
pub struct AnalyticsRefParam {
|
||
/// Optional entity ref filter (action_ref, trigger_ref, rule_ref, or worker name)
|
||
#[param(example = "core.http_request")]
|
||
pub entity_ref: Option<String>,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Response types
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// A single data point in an hourly time series.
|
||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||
pub struct TimeSeriesPoint {
|
||
/// Start of the 1-hour bucket (ISO 8601)
|
||
#[schema(example = "2026-02-26T10:00:00Z")]
|
||
pub bucket: DateTime<Utc>,
|
||
|
||
/// The series label (e.g., status name, action ref). Null for aggregate totals.
|
||
#[schema(example = "completed")]
|
||
pub label: Option<String>,
|
||
|
||
/// The count value for this bucket
|
||
#[schema(example = 42)]
|
||
pub value: i64,
|
||
}
|
||
|
||
/// Response for execution status transitions over time.
|
||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||
pub struct ExecutionStatusTimeSeriesResponse {
|
||
/// Time range start
|
||
pub since: DateTime<Utc>,
|
||
/// Time range end
|
||
pub until: DateTime<Utc>,
|
||
/// Data points: one per (bucket, status) pair
|
||
pub data: Vec<TimeSeriesPoint>,
|
||
}
|
||
|
||
/// Response for execution throughput over time.
|
||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||
pub struct ExecutionThroughputResponse {
|
||
/// Time range start
|
||
pub since: DateTime<Utc>,
|
||
/// Time range end
|
||
pub until: DateTime<Utc>,
|
||
/// Data points: one per bucket (total executions created)
|
||
pub data: Vec<TimeSeriesPoint>,
|
||
}
|
||
|
||
/// Response for event volume over time.
|
||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||
pub struct EventVolumeResponse {
|
||
/// Time range start
|
||
pub since: DateTime<Utc>,
|
||
/// Time range end
|
||
pub until: DateTime<Utc>,
|
||
/// Data points: one per bucket (total events created)
|
||
pub data: Vec<TimeSeriesPoint>,
|
||
}
|
||
|
||
/// Response for worker status transitions over time.
|
||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||
pub struct WorkerStatusTimeSeriesResponse {
|
||
/// Time range start
|
||
pub since: DateTime<Utc>,
|
||
/// Time range end
|
||
pub until: DateTime<Utc>,
|
||
/// Data points: one per (bucket, status) pair
|
||
pub data: Vec<TimeSeriesPoint>,
|
||
}
|
||
|
||
/// Response for enforcement volume over time.
|
||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||
pub struct EnforcementVolumeResponse {
|
||
/// Time range start
|
||
pub since: DateTime<Utc>,
|
||
/// Time range end
|
||
pub until: DateTime<Utc>,
|
||
/// Data points: one per bucket (total enforcements created)
|
||
pub data: Vec<TimeSeriesPoint>,
|
||
}
|
||
|
||
/// Response for the execution failure rate summary.
|
||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||
pub struct FailureRateResponse {
|
||
/// Time range start
|
||
pub since: DateTime<Utc>,
|
||
/// Time range end
|
||
pub until: DateTime<Utc>,
|
||
/// Total executions reaching a terminal state in the window
|
||
#[schema(example = 100)]
|
||
pub total_terminal: i64,
|
||
/// Number of failed executions
|
||
#[schema(example = 12)]
|
||
pub failed_count: i64,
|
||
/// Number of timed-out executions
|
||
#[schema(example = 3)]
|
||
pub timeout_count: i64,
|
||
/// Number of completed executions
|
||
#[schema(example = 85)]
|
||
pub completed_count: i64,
|
||
/// Failure rate as a percentage (0.0 – 100.0)
|
||
#[schema(example = 15.0)]
|
||
pub failure_rate_pct: f64,
|
||
}
|
||
|
||
/// Combined dashboard analytics response.
|
||
///
|
||
/// Returns all key metrics in a single response for the dashboard page,
|
||
/// avoiding multiple round-trips.
|
||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||
pub struct DashboardAnalyticsResponse {
|
||
/// Time range start
|
||
pub since: DateTime<Utc>,
|
||
/// Time range end
|
||
pub until: DateTime<Utc>,
|
||
/// Execution throughput per hour
|
||
pub execution_throughput: Vec<TimeSeriesPoint>,
|
||
/// Execution status transitions per hour
|
||
pub execution_status: Vec<TimeSeriesPoint>,
|
||
/// Event volume per hour
|
||
pub event_volume: Vec<TimeSeriesPoint>,
|
||
/// Enforcement volume per hour
|
||
pub enforcement_volume: Vec<TimeSeriesPoint>,
|
||
/// Worker status transitions per hour
|
||
pub worker_status: Vec<TimeSeriesPoint>,
|
||
/// Execution failure rate summary
|
||
pub failure_rate: FailureRateResponse,
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Conversion helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
impl From<ExecutionStatusBucket> for TimeSeriesPoint {
|
||
fn from(b: ExecutionStatusBucket) -> Self {
|
||
Self {
|
||
bucket: b.bucket,
|
||
label: b.new_status,
|
||
value: b.transition_count,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<ExecutionThroughputBucket> for TimeSeriesPoint {
|
||
fn from(b: ExecutionThroughputBucket) -> Self {
|
||
Self {
|
||
bucket: b.bucket,
|
||
label: b.action_ref,
|
||
value: b.execution_count,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<EventVolumeBucket> for TimeSeriesPoint {
|
||
fn from(b: EventVolumeBucket) -> Self {
|
||
Self {
|
||
bucket: b.bucket,
|
||
label: b.trigger_ref,
|
||
value: b.event_count,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<WorkerStatusBucket> for TimeSeriesPoint {
|
||
fn from(b: WorkerStatusBucket) -> Self {
|
||
Self {
|
||
bucket: b.bucket,
|
||
label: b.new_status,
|
||
value: b.transition_count,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl From<EnforcementVolumeBucket> for TimeSeriesPoint {
|
||
fn from(b: EnforcementVolumeBucket) -> Self {
|
||
Self {
|
||
bucket: b.bucket,
|
||
label: b.rule_ref,
|
||
value: b.enforcement_count,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl FailureRateResponse {
|
||
/// Create from the repository summary plus the query time range.
|
||
pub fn from_summary(summary: FailureRateSummary, range: &AnalyticsTimeRange) -> Self {
|
||
Self {
|
||
since: range.since,
|
||
until: range.until,
|
||
total_terminal: summary.total_terminal,
|
||
failed_count: summary.failed_count,
|
||
timeout_count: summary.timeout_count,
|
||
completed_count: summary.completed_count,
|
||
failure_rate_pct: summary.failure_rate_pct,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_query_params_defaults() {
|
||
let params = AnalyticsQueryParams {
|
||
since: None,
|
||
until: None,
|
||
hours: None,
|
||
};
|
||
let range = params.to_time_range();
|
||
let diff = range.until - range.since;
|
||
assert!((diff.num_hours() - 24).abs() <= 1);
|
||
}
|
||
|
||
#[test]
|
||
fn test_query_params_custom_hours() {
|
||
let params = AnalyticsQueryParams {
|
||
since: None,
|
||
until: None,
|
||
hours: Some(6),
|
||
};
|
||
let range = params.to_time_range();
|
||
let diff = range.until - range.since;
|
||
assert!((diff.num_hours() - 6).abs() <= 1);
|
||
}
|
||
|
||
#[test]
|
||
fn test_query_params_hours_clamped() {
|
||
let params = AnalyticsQueryParams {
|
||
since: None,
|
||
until: None,
|
||
hours: Some(99999),
|
||
};
|
||
let range = params.to_time_range();
|
||
let diff = range.until - range.since;
|
||
// Clamped to 8760 hours (1 year)
|
||
assert!((diff.num_hours() - 8760).abs() <= 1);
|
||
}
|
||
|
||
#[test]
|
||
fn test_query_params_explicit_range() {
|
||
let since = Utc::now() - chrono::Duration::hours(48);
|
||
let until = Utc::now();
|
||
let params = AnalyticsQueryParams {
|
||
since: Some(since),
|
||
until: Some(until),
|
||
hours: Some(6), // ignored when since is provided
|
||
};
|
||
let range = params.to_time_range();
|
||
assert_eq!(range.since, since);
|
||
assert_eq!(range.until, until);
|
||
}
|
||
|
||
#[test]
|
||
fn test_failure_rate_response_from_summary() {
|
||
let summary = FailureRateSummary {
|
||
total_terminal: 100,
|
||
failed_count: 12,
|
||
timeout_count: 3,
|
||
completed_count: 85,
|
||
failure_rate_pct: 15.0,
|
||
};
|
||
let range = AnalyticsTimeRange::last_hours(24);
|
||
let response = FailureRateResponse::from_summary(summary, &range);
|
||
assert_eq!(response.total_terminal, 100);
|
||
assert_eq!(response.failed_count, 12);
|
||
assert_eq!(response.failure_rate_pct, 15.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_time_series_point_from_execution_status_bucket() {
|
||
let bucket = ExecutionStatusBucket {
|
||
bucket: Utc::now(),
|
||
action_ref: Some("core.http".into()),
|
||
new_status: Some("completed".into()),
|
||
transition_count: 10,
|
||
};
|
||
let point: TimeSeriesPoint = bucket.into();
|
||
assert_eq!(point.label.as_deref(), Some("completed"));
|
||
assert_eq!(point.value, 10);
|
||
}
|
||
|
||
#[test]
|
||
fn test_time_series_point_from_event_volume_bucket() {
|
||
let bucket = EventVolumeBucket {
|
||
bucket: Utc::now(),
|
||
trigger_ref: Some("core.timer".into()),
|
||
event_count: 25,
|
||
};
|
||
let point: TimeSeriesPoint = bucket.into();
|
||
assert_eq!(point.label.as_deref(), Some("core.timer"));
|
||
assert_eq!(point.value, 25);
|
||
}
|
||
}
|