1use serde::Serialize;
8use thiserror::Error;
9
10#[derive(Debug, Error)]
16pub enum AnalyticsError {
17 #[error("table '{0}' does not exist — run 'cass analytics rebuild'")]
19 MissingTable(String),
20 #[error("analytics db error: {0}")]
22 Db(String),
23}
24
25pub type AnalyticsResult<T> = std::result::Result<T, AnalyticsError>;
27
28#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize)]
34#[serde(rename_all = "lowercase")]
35pub enum GroupBy {
36 Hour,
37 #[default]
38 Day,
39 Week,
40 Month,
41}
42
43impl GroupBy {
44 pub fn as_str(self) -> &'static str {
46 match self {
47 Self::Hour => "hour",
48 Self::Day => "day",
49 Self::Week => "week",
50 Self::Month => "month",
51 }
52 }
53
54 pub fn label(self) -> &'static str {
56 match self {
57 Self::Hour => "Hourly",
58 Self::Day => "Daily",
59 Self::Week => "Weekly",
60 Self::Month => "Monthly",
61 }
62 }
63
64 pub fn next(self) -> Self {
66 match self {
67 Self::Hour => Self::Day,
68 Self::Day => Self::Week,
69 Self::Week => Self::Month,
70 Self::Month => Self::Hour,
71 }
72 }
73
74 pub fn prev(self) -> Self {
76 match self {
77 Self::Hour => Self::Month,
78 Self::Day => Self::Hour,
79 Self::Week => Self::Day,
80 Self::Month => Self::Week,
81 }
82 }
83}
84
85impl std::fmt::Display for GroupBy {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 f.write_str(self.as_str())
88 }
89}
90
91#[derive(Clone, Debug, Default, PartialEq, Eq)]
97pub enum SourceFilter {
98 #[default]
100 All,
101 Local,
103 Remote,
105 Specific(String),
107}
108
109#[derive(Clone, Debug, Default)]
114pub struct AnalyticsFilter {
115 pub since_ms: Option<i64>,
117 pub until_ms: Option<i64>,
119 pub agents: Vec<String>,
121 pub source: SourceFilter,
123 pub workspace_ids: Vec<i64>,
125}
126
127#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)]
133#[serde(rename_all = "lowercase")]
134pub enum Dim {
135 Agent,
136 Workspace,
137 Source,
138 Model,
139}
140
141impl Dim {
142 pub fn as_str(self) -> &'static str {
144 match self {
145 Self::Agent => "agent",
146 Self::Workspace => "workspace",
147 Self::Source => "source",
148 Self::Model => "model",
149 }
150 }
151}
152
153impl std::fmt::Display for Dim {
154 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155 f.write_str(self.as_str())
156 }
157}
158
159#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize)]
161#[serde(rename_all = "snake_case")]
162pub enum Metric {
163 #[default]
165 ApiTotal,
166 ApiInput,
167 ApiOutput,
168 CacheRead,
169 CacheCreation,
170 Thinking,
171 ContentEstTotal,
173 ToolCalls,
175 PlanCount,
177 CoveragePct,
179 MessageCount,
181 EstimatedCostUsd,
183}
184
185impl Metric {
186 pub fn as_str(self) -> &'static str {
188 match self {
189 Self::ApiTotal => "api_total",
190 Self::ApiInput => "api_input",
191 Self::ApiOutput => "api_output",
192 Self::CacheRead => "cache_read",
193 Self::CacheCreation => "cache_creation",
194 Self::Thinking => "thinking",
195 Self::ContentEstTotal => "content_est_total",
196 Self::ToolCalls => "tool_calls",
197 Self::PlanCount => "plan_count",
198 Self::CoveragePct => "coverage_pct",
199 Self::MessageCount => "message_count",
200 Self::EstimatedCostUsd => "estimated_cost_usd",
201 }
202 }
203
204 pub fn rollup_column(&self) -> Option<&'static str> {
208 match self {
209 Self::ApiTotal => Some("api_tokens_total"),
210 Self::ApiInput => Some("api_input_tokens_total"),
211 Self::ApiOutput => Some("api_output_tokens_total"),
212 Self::CacheRead => Some("api_cache_read_tokens_total"),
213 Self::CacheCreation => Some("api_cache_creation_tokens_total"),
214 Self::Thinking => Some("api_thinking_tokens_total"),
215 Self::ContentEstTotal => Some("content_tokens_est_total"),
216 Self::ToolCalls => Some("tool_call_count"),
217 Self::PlanCount => Some("plan_message_count"),
218 Self::MessageCount => Some("message_count"),
219 Self::CoveragePct => None, Self::EstimatedCostUsd => None, }
222 }
223}
224
225impl std::fmt::Display for Metric {
226 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227 f.write_str(self.as_str())
228 }
229}
230
231#[derive(Debug, Default, Clone, PartialEq, Serialize)]
242pub struct UsageBucket {
243 pub message_count: i64,
244 pub user_message_count: i64,
245 pub assistant_message_count: i64,
246 pub tool_call_count: i64,
247 pub plan_message_count: i64,
248 pub api_coverage_message_count: i64,
249 pub content_tokens_est_total: i64,
250 pub content_tokens_est_user: i64,
251 pub content_tokens_est_assistant: i64,
252 pub api_tokens_total: i64,
253 pub api_input_tokens_total: i64,
254 pub api_output_tokens_total: i64,
255 pub api_cache_read_tokens_total: i64,
256 pub api_cache_creation_tokens_total: i64,
257 pub api_thinking_tokens_total: i64,
258 pub plan_content_tokens_est_total: i64,
259 pub plan_api_tokens_total: i64,
260 pub estimated_cost_usd: f64,
263}
264
265impl UsageBucket {
266 pub fn merge(&mut self, other: &UsageBucket) {
268 self.message_count += other.message_count;
269 self.user_message_count += other.user_message_count;
270 self.assistant_message_count += other.assistant_message_count;
271 self.tool_call_count += other.tool_call_count;
272 self.plan_message_count += other.plan_message_count;
273 self.api_coverage_message_count += other.api_coverage_message_count;
274 self.content_tokens_est_total += other.content_tokens_est_total;
275 self.content_tokens_est_user += other.content_tokens_est_user;
276 self.content_tokens_est_assistant += other.content_tokens_est_assistant;
277 self.api_tokens_total += other.api_tokens_total;
278 self.api_input_tokens_total += other.api_input_tokens_total;
279 self.api_output_tokens_total += other.api_output_tokens_total;
280 self.api_cache_read_tokens_total += other.api_cache_read_tokens_total;
281 self.api_cache_creation_tokens_total += other.api_cache_creation_tokens_total;
282 self.api_thinking_tokens_total += other.api_thinking_tokens_total;
283 self.plan_content_tokens_est_total += other.plan_content_tokens_est_total;
284 self.plan_api_tokens_total += other.plan_api_tokens_total;
285 self.estimated_cost_usd += other.estimated_cost_usd;
286 }
287
288 pub fn to_json(&self, bucket_key: &str) -> serde_json::Value {
292 let derived = super::derive::compute_derived(self);
293
294 serde_json::json!({
295 "bucket": bucket_key,
296 "counts": {
297 "message_count": self.message_count,
298 "user_message_count": self.user_message_count,
299 "assistant_message_count": self.assistant_message_count,
300 "tool_call_count": self.tool_call_count,
301 "plan_message_count": self.plan_message_count,
302 },
303 "content_tokens": {
304 "est_total": self.content_tokens_est_total,
305 "est_user": self.content_tokens_est_user,
306 "est_assistant": self.content_tokens_est_assistant,
307 },
308 "api_tokens": {
309 "total": self.api_tokens_total,
310 "input": self.api_input_tokens_total,
311 "output": self.api_output_tokens_total,
312 "cache_read": self.api_cache_read_tokens_total,
313 "cache_creation": self.api_cache_creation_tokens_total,
314 "thinking": self.api_thinking_tokens_total,
315 },
316 "plan_tokens": {
317 "content_est_total": self.plan_content_tokens_est_total,
318 "api_total": self.plan_api_tokens_total,
319 },
320 "coverage": {
321 "api_coverage_message_count": self.api_coverage_message_count,
322 "api_coverage_pct": derived.api_coverage_pct,
323 },
324 "derived": {
325 "api_tokens_per_assistant_msg": derived.api_tokens_per_assistant_msg,
326 "content_tokens_per_user_msg": derived.content_tokens_per_user_msg,
327 "tool_calls_per_1k_api_tokens": derived.tool_calls_per_1k_api_tokens,
328 "tool_calls_per_1k_content_tokens": derived.tool_calls_per_1k_content_tokens,
329 "plan_message_pct": derived.plan_message_pct,
330 "plan_token_share_content": derived.plan_token_share_content,
331 "plan_token_share_api": derived.plan_token_share_api,
332 },
333 })
334 }
335}
336
337pub struct TimeseriesResult {
343 pub buckets: Vec<(String, UsageBucket)>,
345 pub totals: UsageBucket,
347 pub source_table: String,
349 pub group_by: GroupBy,
351 pub elapsed_ms: u64,
353 pub path: String,
355}
356
357impl TimeseriesResult {
358 pub fn to_cli_json(&self) -> serde_json::Value {
360 let bucket_json: Vec<serde_json::Value> = self
361 .buckets
362 .iter()
363 .map(|(key, row)| row.to_json(key))
364 .collect();
365
366 serde_json::json!({
367 "buckets": bucket_json,
368 "totals": self.totals.to_json("all"),
369 "bucket_count": self.buckets.len(),
370 "_meta": {
371 "elapsed_ms": self.elapsed_ms,
372 "path": self.path,
373 "group_by": self.group_by.to_string(),
374 "source_table": self.source_table,
375 "rows_read": self.buckets.len(),
376 }
377 })
378 }
379}
380
381#[derive(Debug, Clone, Serialize)]
387pub struct BreakdownRow {
388 pub key: String,
390 pub value: i64,
392 pub message_count: i64,
394 pub bucket: UsageBucket,
396}
397
398impl BreakdownRow {
399 pub fn to_json(&self) -> serde_json::Value {
401 let derived = super::derive::compute_derived(&self.bucket);
402 serde_json::json!({
403 "key": self.key,
404 "value": self.value,
405 "message_count": self.message_count,
406 "derived": {
407 "api_coverage_pct": derived.api_coverage_pct,
408 "tool_calls_per_1k_api_tokens": derived.tool_calls_per_1k_api_tokens,
409 "plan_message_pct": derived.plan_message_pct,
410 },
411 })
412 }
413}
414
415pub struct BreakdownResult {
417 pub rows: Vec<BreakdownRow>,
419 pub dim: Dim,
421 pub metric: Metric,
423 pub source_table: String,
425 pub elapsed_ms: u64,
427}
428
429impl BreakdownResult {
430 pub fn to_cli_json(&self) -> serde_json::Value {
432 let rows_json: Vec<serde_json::Value> = self.rows.iter().map(|r| r.to_json()).collect();
433 serde_json::json!({
434 "dim": self.dim.to_string(),
435 "metric": self.metric.to_string(),
436 "rows": rows_json,
437 "row_count": self.rows.len(),
438 "_meta": {
439 "elapsed_ms": self.elapsed_ms,
440 "source_table": self.source_table,
441 }
442 })
443 }
444}
445
446#[derive(Debug, Clone, Serialize)]
452pub struct ToolRow {
453 pub key: String,
455 pub tool_call_count: i64,
457 pub message_count: i64,
459 pub api_tokens_total: i64,
461 pub tool_calls_per_1k_api_tokens: Option<f64>,
463 pub tool_calls_per_1k_content_tokens: Option<f64>,
465}
466
467impl ToolRow {
468 pub fn to_json(&self) -> serde_json::Value {
469 serde_json::json!({
470 "key": self.key,
471 "tool_call_count": self.tool_call_count,
472 "message_count": self.message_count,
473 "api_tokens_total": self.api_tokens_total,
474 "tool_calls_per_1k_api_tokens": self.tool_calls_per_1k_api_tokens,
475 "tool_calls_per_1k_content_tokens": self.tool_calls_per_1k_content_tokens,
476 })
477 }
478}
479
480pub struct ToolReport {
482 pub rows: Vec<ToolRow>,
484 pub total_tool_calls: i64,
486 pub total_messages: i64,
487 pub total_api_tokens: i64,
488 pub source_table: String,
490 pub elapsed_ms: u64,
492}
493
494impl ToolReport {
495 pub fn to_cli_json(&self) -> serde_json::Value {
497 let rows_json: Vec<serde_json::Value> = self.rows.iter().map(|r| r.to_json()).collect();
498 let overall_per_1k = if self.total_api_tokens > 0 {
499 Some(self.total_tool_calls as f64 / (self.total_api_tokens as f64 / 1000.0))
500 } else {
501 None
502 };
503 serde_json::json!({
504 "rows": rows_json,
505 "row_count": self.rows.len(),
506 "totals": {
507 "tool_call_count": self.total_tool_calls,
508 "message_count": self.total_messages,
509 "api_tokens_total": self.total_api_tokens,
510 "tool_calls_per_1k_api_tokens": overall_per_1k,
511 },
512 "_meta": {
513 "elapsed_ms": self.elapsed_ms,
514 "source_table": self.source_table,
515 }
516 })
517 }
518}
519
520#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
526pub struct SessionScatterPoint {
527 pub source_id: String,
529 pub source_path: String,
531 pub message_count: i64,
533 pub api_tokens_total: i64,
535}
536
537#[derive(Debug, Default, Clone, Serialize)]
543pub struct TableInfo {
544 pub table: String,
545 pub exists: bool,
546 pub row_count: i64,
547 pub min_day_id: Option<i64>,
548 pub max_day_id: Option<i64>,
549 pub last_updated: Option<i64>,
550}
551
552impl TableInfo {
553 pub fn to_json(&self) -> serde_json::Value {
554 serde_json::json!({
555 "table": self.table,
556 "exists": self.exists,
557 "row_count": self.row_count,
558 "min_day_id": self.min_day_id,
559 "max_day_id": self.max_day_id,
560 "last_updated": self.last_updated,
561 })
562 }
563}
564
565#[derive(Debug, Default, Clone, Serialize)]
567pub struct CoverageInfo {
568 pub total_messages: i64,
569 pub message_metrics_coverage_pct: f64,
570 pub api_token_coverage_pct: f64,
571 pub model_name_coverage_pct: f64,
572 pub estimate_only_pct: f64,
573}
574
575#[derive(Debug, Default, Clone, Serialize)]
577pub struct DriftInfo {
578 pub signals: Vec<DriftSignal>,
579 pub track_a_fresh: bool,
580 pub track_b_fresh: bool,
581}
582
583#[derive(Debug, Clone, Serialize)]
585pub struct DriftSignal {
586 pub signal: String,
587 pub detail: String,
588 pub severity: String,
589}
590
591impl DriftSignal {
592 fn to_json(&self) -> serde_json::Value {
593 serde_json::json!({
594 "signal": self.signal,
595 "detail": self.detail,
596 "severity": self.severity,
597 })
598 }
599}
600
601pub struct StatusResult {
603 pub tables: Vec<TableInfo>,
604 pub coverage: CoverageInfo,
605 pub drift: DriftInfo,
606 pub recommended_action: String,
607}
608
609impl StatusResult {
610 pub fn to_json(&self) -> serde_json::Value {
612 let tables_json: Vec<serde_json::Value> = self.tables.iter().map(|t| t.to_json()).collect();
613 let signals_json: Vec<serde_json::Value> = self
614 .drift
615 .signals
616 .iter()
617 .map(DriftSignal::to_json)
618 .collect();
619
620 serde_json::json!({
621 "tables": tables_json,
622 "coverage": {
623 "total_messages": self.coverage.total_messages,
624 "message_metrics_coverage_pct": self.coverage.message_metrics_coverage_pct,
625 "api_token_coverage_pct": self.coverage.api_token_coverage_pct,
626 "model_name_coverage_pct": self.coverage.model_name_coverage_pct,
627 "estimate_only_pct": self.coverage.estimate_only_pct,
628 },
629 "drift": {
630 "signals": signals_json,
631 "track_a_fresh": self.drift.track_a_fresh,
632 "track_b_fresh": self.drift.track_b_fresh,
633 },
634 "recommended_action": self.recommended_action,
635 })
636 }
637}
638
639#[derive(Debug, Clone, Serialize)]
645pub struct UnpricedModel {
646 pub model_name: String,
648 pub total_tokens: i64,
650 pub row_count: i64,
652}
653
654#[derive(Debug, Clone, Serialize)]
656pub struct UnpricedModelsReport {
657 pub models: Vec<UnpricedModel>,
659 pub total_unpriced_tokens: i64,
661 pub total_priced_tokens: i64,
663}
664
665#[derive(Debug, Clone, Serialize)]
671pub struct DerivedMetrics {
672 pub api_coverage_pct: f64,
673 pub api_tokens_per_assistant_msg: Option<f64>,
674 pub content_tokens_per_user_msg: Option<f64>,
675 pub tool_calls_per_1k_api_tokens: Option<f64>,
676 pub tool_calls_per_1k_content_tokens: Option<f64>,
677 pub plan_message_pct: Option<f64>,
678 pub plan_token_share_content: Option<f64>,
679 pub plan_token_share_api: Option<f64>,
680}
681
682#[cfg(test)]
687mod tests {
688 use super::*;
689
690 const GROUP_BY_CASES: [(GroupBy, &str, &str, GroupBy, GroupBy); 4] = [
691 (
692 GroupBy::Hour,
693 "hour",
694 "Hourly",
695 GroupBy::Day,
696 GroupBy::Month,
697 ),
698 (GroupBy::Day, "day", "Daily", GroupBy::Week, GroupBy::Hour),
699 (
700 GroupBy::Week,
701 "week",
702 "Weekly",
703 GroupBy::Month,
704 GroupBy::Day,
705 ),
706 (
707 GroupBy::Month,
708 "month",
709 "Monthly",
710 GroupBy::Hour,
711 GroupBy::Week,
712 ),
713 ];
714
715 #[test]
716 fn analytics_error_display_and_sources_are_preserved() {
717 let missing = AnalyticsError::MissingTable("usage_daily".to_string());
718 assert_eq!(
719 missing.to_string(),
720 "table 'usage_daily' does not exist — run 'cass analytics rebuild'"
721 );
722 assert!(std::error::Error::source(&missing).is_none());
723
724 let db = AnalyticsError::Db("query failed".to_string());
725 assert_eq!(db.to_string(), "analytics db error: query failed");
726 assert!(std::error::Error::source(&db).is_none());
727 }
728
729 #[test]
730 fn usage_bucket_merge_is_additive() {
731 let mut a = UsageBucket {
732 message_count: 10,
733 user_message_count: 5,
734 assistant_message_count: 5,
735 tool_call_count: 3,
736 api_tokens_total: 1000,
737 api_input_tokens_total: 600,
738 api_output_tokens_total: 400,
739 estimated_cost_usd: 0.50,
740 ..Default::default()
741 };
742 let b = UsageBucket {
743 message_count: 20,
744 user_message_count: 10,
745 assistant_message_count: 10,
746 tool_call_count: 7,
747 api_tokens_total: 2000,
748 api_input_tokens_total: 1200,
749 api_output_tokens_total: 800,
750 estimated_cost_usd: 1.25,
751 ..Default::default()
752 };
753 a.merge(&b);
754 assert_eq!(a.message_count, 30);
755 assert_eq!(a.user_message_count, 15);
756 assert_eq!(a.assistant_message_count, 15);
757 assert_eq!(a.tool_call_count, 10);
758 assert_eq!(a.api_tokens_total, 3000);
759 assert_eq!(a.api_input_tokens_total, 1800);
760 assert_eq!(a.api_output_tokens_total, 1200);
761 assert!((a.estimated_cost_usd - 1.75).abs() < 0.001);
762 }
763
764 #[test]
765 fn usage_bucket_to_json_shape() {
766 let bucket = UsageBucket {
767 message_count: 100,
768 assistant_message_count: 50,
769 plan_message_count: 10,
770 plan_content_tokens_est_total: 1_000,
771 plan_api_tokens_total: 2_000,
772 content_tokens_est_total: 10_000,
773 api_tokens_total: 5000,
774 api_coverage_message_count: 80,
775 estimated_cost_usd: 2.50,
776 ..Default::default()
777 };
778 let json = bucket.to_json("2025-01-15");
779 assert_eq!(json["bucket"], "2025-01-15");
780 assert!(json["counts"]["message_count"].is_number());
781 assert!(json["plan_tokens"]["content_est_total"].is_number());
782 assert!(json["content_tokens"]["est_total"].is_number());
783 assert!(json["api_tokens"]["total"].is_number());
784 assert!(json["coverage"]["api_coverage_pct"].is_number());
785 assert!(json["derived"].is_object());
786 assert!(json["derived"]["plan_token_share_content"].is_number());
787 assert!(json["derived"]["plan_token_share_api"].is_number());
788 }
789
790 #[test]
791 fn group_by_display() {
792 for (group_by, expected_display, _, _, _) in GROUP_BY_CASES {
793 assert_eq!(group_by.as_str(), expected_display, "{group_by:?}");
794 assert_eq!(group_by.to_string(), expected_display, "{group_by:?}");
795 }
796 }
797
798 #[test]
799 fn group_by_next_cycles_through_all() {
800 for (group_by, _, _, expected_next, _) in GROUP_BY_CASES {
801 assert_eq!(group_by.next(), expected_next, "{group_by:?}");
802 }
803 }
804
805 #[test]
806 fn group_by_prev_cycles_through_all() {
807 for (group_by, _, _, _, expected_prev) in GROUP_BY_CASES {
808 assert_eq!(group_by.prev(), expected_prev, "{group_by:?}");
809 }
810 }
811
812 #[test]
813 fn group_by_label() {
814 for (group_by, _, expected_label, _, _) in GROUP_BY_CASES {
815 assert_eq!(group_by.label(), expected_label, "{group_by:?}");
816 }
817 }
818
819 #[test]
820 fn dim_as_str_matches_display_for_all_variants() {
821 let cases = [
822 (Dim::Agent, "agent"),
823 (Dim::Workspace, "workspace"),
824 (Dim::Source, "source"),
825 (Dim::Model, "model"),
826 ];
827
828 for (dim, expected) in cases {
829 assert_eq!(dim.as_str(), expected, "{dim:?}");
830 assert_eq!(dim.to_string(), expected, "{dim:?}");
831 }
832 }
833
834 #[test]
835 fn metric_as_str_matches_display_for_all_variants() {
836 let cases = [
837 (Metric::ApiTotal, "api_total"),
838 (Metric::ApiInput, "api_input"),
839 (Metric::ApiOutput, "api_output"),
840 (Metric::CacheRead, "cache_read"),
841 (Metric::CacheCreation, "cache_creation"),
842 (Metric::Thinking, "thinking"),
843 (Metric::ContentEstTotal, "content_est_total"),
844 (Metric::ToolCalls, "tool_calls"),
845 (Metric::PlanCount, "plan_count"),
846 (Metric::CoveragePct, "coverage_pct"),
847 (Metric::MessageCount, "message_count"),
848 (Metric::EstimatedCostUsd, "estimated_cost_usd"),
849 ];
850
851 for (metric, expected) in cases {
852 assert_eq!(metric.as_str(), expected);
853 assert_eq!(metric.to_string(), expected);
854 }
855 }
856
857 #[test]
858 fn drift_signal_to_json_shape() {
859 let signal = DriftSignal {
860 signal: "track-a-stale".to_string(),
861 detail: "usage_daily is older than token_usage".to_string(),
862 severity: "warning".to_string(),
863 };
864
865 let json = signal.to_json();
866 assert_eq!(json["signal"], "track-a-stale");
867 assert_eq!(json["detail"], "usage_daily is older than token_usage");
868 assert_eq!(json["severity"], "warning");
869 assert_eq!(json.as_object().expect("object").len(), 3);
870 }
871
872 #[test]
873 fn default_filter_is_unfiltered() {
874 let f = AnalyticsFilter::default();
875 assert!(f.since_ms.is_none());
876 assert!(f.until_ms.is_none());
877 assert!(f.agents.is_empty());
878 assert_eq!(f.source, SourceFilter::All);
879 assert!(f.workspace_ids.is_empty());
880 }
881}