use serde::Serialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AnalyticsError {
#[error("table '{0}' does not exist — run 'cass analytics rebuild'")]
MissingTable(String),
#[error("analytics db error: {0}")]
Db(String),
}
pub type AnalyticsResult<T> = std::result::Result<T, AnalyticsError>;
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum GroupBy {
Hour,
#[default]
Day,
Week,
Month,
}
impl GroupBy {
pub fn as_str(self) -> &'static str {
match self {
Self::Hour => "hour",
Self::Day => "day",
Self::Week => "week",
Self::Month => "month",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Hour => "Hourly",
Self::Day => "Daily",
Self::Week => "Weekly",
Self::Month => "Monthly",
}
}
pub fn next(self) -> Self {
match self {
Self::Hour => Self::Day,
Self::Day => Self::Week,
Self::Week => Self::Month,
Self::Month => Self::Hour,
}
}
pub fn prev(self) -> Self {
match self {
Self::Hour => Self::Month,
Self::Day => Self::Hour,
Self::Week => Self::Day,
Self::Month => Self::Week,
}
}
}
impl std::fmt::Display for GroupBy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum SourceFilter {
#[default]
All,
Local,
Remote,
Specific(String),
}
#[derive(Clone, Debug, Default)]
pub struct AnalyticsFilter {
pub since_ms: Option<i64>,
pub until_ms: Option<i64>,
pub agents: Vec<String>,
pub source: SourceFilter,
pub workspace_ids: Vec<i64>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Dim {
Agent,
Workspace,
Source,
Model,
}
impl Dim {
pub fn as_str(self) -> &'static str {
match self {
Self::Agent => "agent",
Self::Workspace => "workspace",
Self::Source => "source",
Self::Model => "model",
}
}
}
impl std::fmt::Display for Dim {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Metric {
#[default]
ApiTotal,
ApiInput,
ApiOutput,
CacheRead,
CacheCreation,
Thinking,
ContentEstTotal,
ToolCalls,
PlanCount,
CoveragePct,
MessageCount,
EstimatedCostUsd,
}
impl Metric {
pub fn as_str(self) -> &'static str {
match self {
Self::ApiTotal => "api_total",
Self::ApiInput => "api_input",
Self::ApiOutput => "api_output",
Self::CacheRead => "cache_read",
Self::CacheCreation => "cache_creation",
Self::Thinking => "thinking",
Self::ContentEstTotal => "content_est_total",
Self::ToolCalls => "tool_calls",
Self::PlanCount => "plan_count",
Self::CoveragePct => "coverage_pct",
Self::MessageCount => "message_count",
Self::EstimatedCostUsd => "estimated_cost_usd",
}
}
pub fn rollup_column(&self) -> Option<&'static str> {
match self {
Self::ApiTotal => Some("api_tokens_total"),
Self::ApiInput => Some("api_input_tokens_total"),
Self::ApiOutput => Some("api_output_tokens_total"),
Self::CacheRead => Some("api_cache_read_tokens_total"),
Self::CacheCreation => Some("api_cache_creation_tokens_total"),
Self::Thinking => Some("api_thinking_tokens_total"),
Self::ContentEstTotal => Some("content_tokens_est_total"),
Self::ToolCalls => Some("tool_call_count"),
Self::PlanCount => Some("plan_message_count"),
Self::MessageCount => Some("message_count"),
Self::CoveragePct => None, Self::EstimatedCostUsd => None, }
}
}
impl std::fmt::Display for Metric {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Default, Clone, PartialEq, Serialize)]
pub struct UsageBucket {
pub message_count: i64,
pub user_message_count: i64,
pub assistant_message_count: i64,
pub tool_call_count: i64,
pub plan_message_count: i64,
pub api_coverage_message_count: i64,
pub content_tokens_est_total: i64,
pub content_tokens_est_user: i64,
pub content_tokens_est_assistant: i64,
pub api_tokens_total: i64,
pub api_input_tokens_total: i64,
pub api_output_tokens_total: i64,
pub api_cache_read_tokens_total: i64,
pub api_cache_creation_tokens_total: i64,
pub api_thinking_tokens_total: i64,
pub plan_content_tokens_est_total: i64,
pub plan_api_tokens_total: i64,
pub estimated_cost_usd: f64,
}
impl UsageBucket {
pub fn merge(&mut self, other: &UsageBucket) {
self.message_count += other.message_count;
self.user_message_count += other.user_message_count;
self.assistant_message_count += other.assistant_message_count;
self.tool_call_count += other.tool_call_count;
self.plan_message_count += other.plan_message_count;
self.api_coverage_message_count += other.api_coverage_message_count;
self.content_tokens_est_total += other.content_tokens_est_total;
self.content_tokens_est_user += other.content_tokens_est_user;
self.content_tokens_est_assistant += other.content_tokens_est_assistant;
self.api_tokens_total += other.api_tokens_total;
self.api_input_tokens_total += other.api_input_tokens_total;
self.api_output_tokens_total += other.api_output_tokens_total;
self.api_cache_read_tokens_total += other.api_cache_read_tokens_total;
self.api_cache_creation_tokens_total += other.api_cache_creation_tokens_total;
self.api_thinking_tokens_total += other.api_thinking_tokens_total;
self.plan_content_tokens_est_total += other.plan_content_tokens_est_total;
self.plan_api_tokens_total += other.plan_api_tokens_total;
self.estimated_cost_usd += other.estimated_cost_usd;
}
pub fn to_json(&self, bucket_key: &str) -> serde_json::Value {
let derived = super::derive::compute_derived(self);
serde_json::json!({
"bucket": bucket_key,
"counts": {
"message_count": self.message_count,
"user_message_count": self.user_message_count,
"assistant_message_count": self.assistant_message_count,
"tool_call_count": self.tool_call_count,
"plan_message_count": self.plan_message_count,
},
"content_tokens": {
"est_total": self.content_tokens_est_total,
"est_user": self.content_tokens_est_user,
"est_assistant": self.content_tokens_est_assistant,
},
"api_tokens": {
"total": self.api_tokens_total,
"input": self.api_input_tokens_total,
"output": self.api_output_tokens_total,
"cache_read": self.api_cache_read_tokens_total,
"cache_creation": self.api_cache_creation_tokens_total,
"thinking": self.api_thinking_tokens_total,
},
"plan_tokens": {
"content_est_total": self.plan_content_tokens_est_total,
"api_total": self.plan_api_tokens_total,
},
"coverage": {
"api_coverage_message_count": self.api_coverage_message_count,
"api_coverage_pct": derived.api_coverage_pct,
},
"derived": {
"api_tokens_per_assistant_msg": derived.api_tokens_per_assistant_msg,
"content_tokens_per_user_msg": derived.content_tokens_per_user_msg,
"tool_calls_per_1k_api_tokens": derived.tool_calls_per_1k_api_tokens,
"tool_calls_per_1k_content_tokens": derived.tool_calls_per_1k_content_tokens,
"plan_message_pct": derived.plan_message_pct,
"plan_token_share_content": derived.plan_token_share_content,
"plan_token_share_api": derived.plan_token_share_api,
},
})
}
}
pub struct TimeseriesResult {
pub buckets: Vec<(String, UsageBucket)>,
pub totals: UsageBucket,
pub source_table: String,
pub group_by: GroupBy,
pub elapsed_ms: u64,
pub path: String,
}
impl TimeseriesResult {
pub fn to_cli_json(&self) -> serde_json::Value {
let bucket_json: Vec<serde_json::Value> = self
.buckets
.iter()
.map(|(key, row)| row.to_json(key))
.collect();
serde_json::json!({
"buckets": bucket_json,
"totals": self.totals.to_json("all"),
"bucket_count": self.buckets.len(),
"_meta": {
"elapsed_ms": self.elapsed_ms,
"path": self.path,
"group_by": self.group_by.to_string(),
"source_table": self.source_table,
"rows_read": self.buckets.len(),
}
})
}
}
#[derive(Debug, Clone, Serialize)]
pub struct BreakdownRow {
pub key: String,
pub value: i64,
pub message_count: i64,
pub bucket: UsageBucket,
}
impl BreakdownRow {
pub fn to_json(&self) -> serde_json::Value {
let derived = super::derive::compute_derived(&self.bucket);
serde_json::json!({
"key": self.key,
"value": self.value,
"message_count": self.message_count,
"derived": {
"api_coverage_pct": derived.api_coverage_pct,
"tool_calls_per_1k_api_tokens": derived.tool_calls_per_1k_api_tokens,
"plan_message_pct": derived.plan_message_pct,
},
})
}
}
pub struct BreakdownResult {
pub rows: Vec<BreakdownRow>,
pub dim: Dim,
pub metric: Metric,
pub source_table: String,
pub elapsed_ms: u64,
}
impl BreakdownResult {
pub fn to_cli_json(&self) -> serde_json::Value {
let rows_json: Vec<serde_json::Value> = self.rows.iter().map(|r| r.to_json()).collect();
serde_json::json!({
"dim": self.dim.to_string(),
"metric": self.metric.to_string(),
"rows": rows_json,
"row_count": self.rows.len(),
"_meta": {
"elapsed_ms": self.elapsed_ms,
"source_table": self.source_table,
}
})
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ToolRow {
pub key: String,
pub tool_call_count: i64,
pub message_count: i64,
pub api_tokens_total: i64,
pub tool_calls_per_1k_api_tokens: Option<f64>,
pub tool_calls_per_1k_content_tokens: Option<f64>,
}
impl ToolRow {
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"key": self.key,
"tool_call_count": self.tool_call_count,
"message_count": self.message_count,
"api_tokens_total": self.api_tokens_total,
"tool_calls_per_1k_api_tokens": self.tool_calls_per_1k_api_tokens,
"tool_calls_per_1k_content_tokens": self.tool_calls_per_1k_content_tokens,
})
}
}
pub struct ToolReport {
pub rows: Vec<ToolRow>,
pub total_tool_calls: i64,
pub total_messages: i64,
pub total_api_tokens: i64,
pub source_table: String,
pub elapsed_ms: u64,
}
impl ToolReport {
pub fn to_cli_json(&self) -> serde_json::Value {
let rows_json: Vec<serde_json::Value> = self.rows.iter().map(|r| r.to_json()).collect();
let overall_per_1k = if self.total_api_tokens > 0 {
Some(self.total_tool_calls as f64 / (self.total_api_tokens as f64 / 1000.0))
} else {
None
};
serde_json::json!({
"rows": rows_json,
"row_count": self.rows.len(),
"totals": {
"tool_call_count": self.total_tool_calls,
"message_count": self.total_messages,
"api_tokens_total": self.total_api_tokens,
"tool_calls_per_1k_api_tokens": overall_per_1k,
},
"_meta": {
"elapsed_ms": self.elapsed_ms,
"source_table": self.source_table,
}
})
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct SessionScatterPoint {
pub source_id: String,
pub source_path: String,
pub message_count: i64,
pub api_tokens_total: i64,
}
#[derive(Debug, Default, Clone, Serialize)]
pub struct TableInfo {
pub table: String,
pub exists: bool,
pub row_count: i64,
pub min_day_id: Option<i64>,
pub max_day_id: Option<i64>,
pub last_updated: Option<i64>,
}
impl TableInfo {
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"table": self.table,
"exists": self.exists,
"row_count": self.row_count,
"min_day_id": self.min_day_id,
"max_day_id": self.max_day_id,
"last_updated": self.last_updated,
})
}
}
#[derive(Debug, Default, Clone, Serialize)]
pub struct CoverageInfo {
pub total_messages: i64,
pub message_metrics_coverage_pct: f64,
pub api_token_coverage_pct: f64,
pub model_name_coverage_pct: f64,
pub estimate_only_pct: f64,
}
#[derive(Debug, Default, Clone, Serialize)]
pub struct DriftInfo {
pub signals: Vec<DriftSignal>,
pub track_a_fresh: bool,
pub track_b_fresh: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct DriftSignal {
pub signal: String,
pub detail: String,
pub severity: String,
}
impl DriftSignal {
fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"signal": self.signal,
"detail": self.detail,
"severity": self.severity,
})
}
}
pub struct StatusResult {
pub tables: Vec<TableInfo>,
pub coverage: CoverageInfo,
pub drift: DriftInfo,
pub recommended_action: String,
}
impl StatusResult {
pub fn to_json(&self) -> serde_json::Value {
let tables_json: Vec<serde_json::Value> = self.tables.iter().map(|t| t.to_json()).collect();
let signals_json: Vec<serde_json::Value> = self
.drift
.signals
.iter()
.map(DriftSignal::to_json)
.collect();
serde_json::json!({
"tables": tables_json,
"coverage": {
"total_messages": self.coverage.total_messages,
"message_metrics_coverage_pct": self.coverage.message_metrics_coverage_pct,
"api_token_coverage_pct": self.coverage.api_token_coverage_pct,
"model_name_coverage_pct": self.coverage.model_name_coverage_pct,
"estimate_only_pct": self.coverage.estimate_only_pct,
},
"drift": {
"signals": signals_json,
"track_a_fresh": self.drift.track_a_fresh,
"track_b_fresh": self.drift.track_b_fresh,
},
"recommended_action": self.recommended_action,
})
}
}
#[derive(Debug, Clone, Serialize)]
pub struct UnpricedModel {
pub model_name: String,
pub total_tokens: i64,
pub row_count: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct UnpricedModelsReport {
pub models: Vec<UnpricedModel>,
pub total_unpriced_tokens: i64,
pub total_priced_tokens: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct DerivedMetrics {
pub api_coverage_pct: f64,
pub api_tokens_per_assistant_msg: Option<f64>,
pub content_tokens_per_user_msg: Option<f64>,
pub tool_calls_per_1k_api_tokens: Option<f64>,
pub tool_calls_per_1k_content_tokens: Option<f64>,
pub plan_message_pct: Option<f64>,
pub plan_token_share_content: Option<f64>,
pub plan_token_share_api: Option<f64>,
}
#[cfg(test)]
mod tests {
use super::*;
const GROUP_BY_CASES: [(GroupBy, &str, &str, GroupBy, GroupBy); 4] = [
(
GroupBy::Hour,
"hour",
"Hourly",
GroupBy::Day,
GroupBy::Month,
),
(GroupBy::Day, "day", "Daily", GroupBy::Week, GroupBy::Hour),
(
GroupBy::Week,
"week",
"Weekly",
GroupBy::Month,
GroupBy::Day,
),
(
GroupBy::Month,
"month",
"Monthly",
GroupBy::Hour,
GroupBy::Week,
),
];
#[test]
fn analytics_error_display_and_sources_are_preserved() {
let missing = AnalyticsError::MissingTable("usage_daily".to_string());
assert_eq!(
missing.to_string(),
"table 'usage_daily' does not exist — run 'cass analytics rebuild'"
);
assert!(std::error::Error::source(&missing).is_none());
let db = AnalyticsError::Db("query failed".to_string());
assert_eq!(db.to_string(), "analytics db error: query failed");
assert!(std::error::Error::source(&db).is_none());
}
#[test]
fn usage_bucket_merge_is_additive() {
let mut a = UsageBucket {
message_count: 10,
user_message_count: 5,
assistant_message_count: 5,
tool_call_count: 3,
api_tokens_total: 1000,
api_input_tokens_total: 600,
api_output_tokens_total: 400,
estimated_cost_usd: 0.50,
..Default::default()
};
let b = UsageBucket {
message_count: 20,
user_message_count: 10,
assistant_message_count: 10,
tool_call_count: 7,
api_tokens_total: 2000,
api_input_tokens_total: 1200,
api_output_tokens_total: 800,
estimated_cost_usd: 1.25,
..Default::default()
};
a.merge(&b);
assert_eq!(a.message_count, 30);
assert_eq!(a.user_message_count, 15);
assert_eq!(a.assistant_message_count, 15);
assert_eq!(a.tool_call_count, 10);
assert_eq!(a.api_tokens_total, 3000);
assert_eq!(a.api_input_tokens_total, 1800);
assert_eq!(a.api_output_tokens_total, 1200);
assert!((a.estimated_cost_usd - 1.75).abs() < 0.001);
}
#[test]
fn usage_bucket_to_json_shape() {
let bucket = UsageBucket {
message_count: 100,
assistant_message_count: 50,
plan_message_count: 10,
plan_content_tokens_est_total: 1_000,
plan_api_tokens_total: 2_000,
content_tokens_est_total: 10_000,
api_tokens_total: 5000,
api_coverage_message_count: 80,
estimated_cost_usd: 2.50,
..Default::default()
};
let json = bucket.to_json("2025-01-15");
assert_eq!(json["bucket"], "2025-01-15");
assert!(json["counts"]["message_count"].is_number());
assert!(json["plan_tokens"]["content_est_total"].is_number());
assert!(json["content_tokens"]["est_total"].is_number());
assert!(json["api_tokens"]["total"].is_number());
assert!(json["coverage"]["api_coverage_pct"].is_number());
assert!(json["derived"].is_object());
assert!(json["derived"]["plan_token_share_content"].is_number());
assert!(json["derived"]["plan_token_share_api"].is_number());
}
#[test]
fn group_by_display() {
for (group_by, expected_display, _, _, _) in GROUP_BY_CASES {
assert_eq!(group_by.as_str(), expected_display, "{group_by:?}");
assert_eq!(group_by.to_string(), expected_display, "{group_by:?}");
}
}
#[test]
fn group_by_next_cycles_through_all() {
for (group_by, _, _, expected_next, _) in GROUP_BY_CASES {
assert_eq!(group_by.next(), expected_next, "{group_by:?}");
}
}
#[test]
fn group_by_prev_cycles_through_all() {
for (group_by, _, _, _, expected_prev) in GROUP_BY_CASES {
assert_eq!(group_by.prev(), expected_prev, "{group_by:?}");
}
}
#[test]
fn group_by_label() {
for (group_by, _, expected_label, _, _) in GROUP_BY_CASES {
assert_eq!(group_by.label(), expected_label, "{group_by:?}");
}
}
#[test]
fn dim_as_str_matches_display_for_all_variants() {
let cases = [
(Dim::Agent, "agent"),
(Dim::Workspace, "workspace"),
(Dim::Source, "source"),
(Dim::Model, "model"),
];
for (dim, expected) in cases {
assert_eq!(dim.as_str(), expected, "{dim:?}");
assert_eq!(dim.to_string(), expected, "{dim:?}");
}
}
#[test]
fn metric_as_str_matches_display_for_all_variants() {
let cases = [
(Metric::ApiTotal, "api_total"),
(Metric::ApiInput, "api_input"),
(Metric::ApiOutput, "api_output"),
(Metric::CacheRead, "cache_read"),
(Metric::CacheCreation, "cache_creation"),
(Metric::Thinking, "thinking"),
(Metric::ContentEstTotal, "content_est_total"),
(Metric::ToolCalls, "tool_calls"),
(Metric::PlanCount, "plan_count"),
(Metric::CoveragePct, "coverage_pct"),
(Metric::MessageCount, "message_count"),
(Metric::EstimatedCostUsd, "estimated_cost_usd"),
];
for (metric, expected) in cases {
assert_eq!(metric.as_str(), expected);
assert_eq!(metric.to_string(), expected);
}
}
#[test]
fn drift_signal_to_json_shape() {
let signal = DriftSignal {
signal: "track-a-stale".to_string(),
detail: "usage_daily is older than token_usage".to_string(),
severity: "warning".to_string(),
};
let json = signal.to_json();
assert_eq!(json["signal"], "track-a-stale");
assert_eq!(json["detail"], "usage_daily is older than token_usage");
assert_eq!(json["severity"], "warning");
assert_eq!(json.as_object().expect("object").len(), 3);
}
#[test]
fn default_filter_is_unfiltered() {
let f = AnalyticsFilter::default();
assert!(f.since_ms.is_none());
assert!(f.until_ms.is_none());
assert!(f.agents.is_empty());
assert_eq!(f.source, SourceFilter::All);
assert!(f.workspace_ids.is_empty());
}
}