Skip to main content

chio_kernel/
receipt_analytics.rs

1use serde::{Deserialize, Serialize};
2
3use crate::receipt_query::ReceiptReadContext;
4
5/// Maximum number of grouped analytics rows to return per dimension.
6pub const MAX_ANALYTICS_GROUP_LIMIT: usize = 200;
7
8/// Supported time bucket widths for aggregated receipt analytics.
9#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "snake_case")]
11pub enum AnalyticsTimeBucket {
12    Hour,
13    Day,
14}
15
16impl AnalyticsTimeBucket {
17    #[must_use]
18    pub fn width_secs(self) -> u64 {
19        match self {
20            Self::Hour => 3_600,
21            Self::Day => 86_400,
22        }
23    }
24}
25
26/// Filters for aggregated receipt analytics.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct ReceiptAnalyticsQuery {
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub capability_id: Option<String>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub agent_subject: Option<String>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub tool_server: Option<String>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub tool_name: Option<String>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub since: Option<u64>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub until: Option<u64>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub group_limit: Option<usize>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub time_bucket: Option<AnalyticsTimeBucket>,
46    /// Auth-derived read authority. This is never accepted from request bodies.
47    #[serde(skip)]
48    pub read_context: Option<ReceiptReadContext>,
49}
50
51impl Default for ReceiptAnalyticsQuery {
52    fn default() -> Self {
53        Self {
54            capability_id: None,
55            agent_subject: None,
56            tool_server: None,
57            tool_name: None,
58            since: None,
59            until: None,
60            group_limit: Some(50),
61            time_bucket: Some(AnalyticsTimeBucket::Day),
62            read_context: None,
63        }
64    }
65}
66
67/// Shared aggregated metrics derived from receipts.
68#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
69#[serde(rename_all = "camelCase")]
70pub struct ReceiptAnalyticsMetrics {
71    pub total_receipts: u64,
72    pub allow_count: u64,
73    pub deny_count: u64,
74    pub cancelled_count: u64,
75    pub incomplete_count: u64,
76    pub total_cost_charged: u64,
77    pub total_attempted_cost: u64,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub reliability_score: Option<f64>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub compliance_rate: Option<f64>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub budget_utilization_rate: Option<f64>,
84}
85
86impl ReceiptAnalyticsMetrics {
87    #[must_use]
88    pub fn from_raw(
89        total_receipts: u64,
90        allow_count: u64,
91        deny_count: u64,
92        cancelled_count: u64,
93        incomplete_count: u64,
94        total_cost_charged: u64,
95        total_attempted_cost: u64,
96    ) -> Self {
97        let terminal_total = allow_count
98            .saturating_add(cancelled_count)
99            .saturating_add(incomplete_count);
100        let attempted_total = total_cost_charged.saturating_add(total_attempted_cost);
101
102        Self {
103            total_receipts,
104            allow_count,
105            deny_count,
106            cancelled_count,
107            incomplete_count,
108            total_cost_charged,
109            total_attempted_cost,
110            reliability_score: ratio_option(allow_count, terminal_total),
111            compliance_rate: ratio_option(
112                total_receipts.saturating_sub(deny_count),
113                total_receipts,
114            ),
115            budget_utilization_rate: ratio_option(total_cost_charged, attempted_total),
116        }
117    }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121#[serde(rename_all = "camelCase")]
122pub struct AgentAnalyticsRow {
123    pub subject_key: String,
124    pub metrics: ReceiptAnalyticsMetrics,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
128#[serde(rename_all = "camelCase")]
129pub struct ToolAnalyticsRow {
130    pub tool_server: String,
131    pub tool_name: String,
132    pub metrics: ReceiptAnalyticsMetrics,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136#[serde(rename_all = "camelCase")]
137pub struct TimeAnalyticsRow {
138    pub bucket_start: u64,
139    pub bucket_end: u64,
140    pub metrics: ReceiptAnalyticsMetrics,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
144#[serde(rename_all = "camelCase")]
145pub struct ReceiptAnalyticsResponse {
146    pub summary: ReceiptAnalyticsMetrics,
147    pub by_agent: Vec<AgentAnalyticsRow>,
148    pub by_tool: Vec<ToolAnalyticsRow>,
149    pub by_time: Vec<TimeAnalyticsRow>,
150}
151
152fn ratio_option(numerator: u64, denominator: u64) -> Option<f64> {
153    if denominator == 0 {
154        None
155    } else {
156        Some(numerator as f64 / denominator as f64)
157    }
158}