use crate::pricing::{Plan, Provider};
use crate::session::{ActivityState, Session, Surface};
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
#[derive(Serialize)]
pub struct Analytics {
pub generated: String,
pub plan: &'static str,
pub sessions: Vec<AnalyticsSession>,
}
#[derive(Serialize)]
pub struct AnalyticsTokens {
pub input: u64,
pub output: u64,
pub cache_read: u64,
pub cache_write: u64,
pub total: u64,
}
#[derive(Serialize)]
pub struct AnalyticsSession {
pub id: String,
pub provider: &'static str,
pub label: &'static str,
pub surface: &'static str,
pub model: String,
pub models: Vec<String>,
pub harness: String,
pub project: String,
pub project_name: String,
pub title: Option<String>,
pub user: Option<String>,
pub profile: Option<String>,
pub account: Option<String>,
pub host: Option<String>,
pub branch: Option<String>,
pub started: String,
pub last_active: String,
pub running: bool,
pub state: &'static str,
pub tokens: AnalyticsTokens,
pub cost: Option<f64>,
pub cost_available: bool,
pub cost_included: bool,
pub cost_free: bool,
pub tools: u64,
pub tool_errors: Option<u64>,
pub tool_names: HashMap<String, u64>,
pub lines_added: u64,
pub lines_removed: u64,
pub subagents: usize,
pub subagent_cost: f64,
pub by_day: HashMap<String, HashMap<String, f64>>,
pub by_hour: HashMap<String, HashMap<String, f64>>,
pub tokens_by_day: HashMap<String, HashMap<String, u64>>,
pub tokens_by_hour: HashMap<String, HashMap<String, u64>>,
pub writes: Vec<String>,
}
pub fn build(sessions: &[Session], plan: Plan, store: &crate::cache::Store) -> Analytics {
let claude_account = crate::quota::claude_account();
let codex_account = crate::quota::codex_account();
Analytics {
generated: crate::util::ms_to_rfc3339(crate::util::now_ms()),
plan: plan.as_str(),
sessions: sessions
.iter()
.map(|s| row(s, plan, store, &claude_account, &codex_account))
.collect(),
}
}
fn row(
s: &Session,
plan: Plan,
store: &crate::cache::Store,
claude_account: &Option<crate::quota::Account>,
codex_account: &Option<crate::quota::Account>,
) -> AnalyticsSession {
let data = store.session_data(s);
let included = s.cost_available && plan.includes(s.provider);
let account = match s.provider {
Provider::Claude if s.owner.is_none() => claude_account.as_ref(),
Provider::Codex if s.owner.is_none() => codex_account.as_ref(),
_ => None,
}
.and_then(|a| a.email.clone().or_else(|| a.organization.clone()));
AnalyticsSession {
id: s.session_id.clone(),
provider: s.provider.as_str(),
label: s.surface.label(s.provider),
surface: match s.surface {
Surface::Cli => "cli",
Surface::Editor => "editor",
Surface::DesktopCode => "desktop-code",
Surface::DesktopCowork => "desktop-cowork",
},
model: s.model.clone(),
models: data.models.clone(),
harness: s.harness.clone(),
project: s.label_source.clone(),
project_name: if s.abbrev_label.is_empty() {
Path::new(&s.label_source)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| s.label_source.clone())
} else {
s.abbrev_label.clone()
},
title: s.title.clone(),
user: s.owner.clone(),
profile: s.profile.clone(),
account,
host: s.remote.as_ref().map(|r| r.host.clone()),
branch: crate::ui::columns::branch_of(s),
started: s.started_at.clone(),
last_active: s.last_active.clone(),
running: s.is_running(),
state: match s.activity_state {
ActivityState::Working => "working",
ActivityState::WaitingForInput => "waiting",
ActivityState::Asking => "asking",
ActivityState::ApiError => "error",
},
tokens: if s.remote.is_some() {
AnalyticsTokens {
input: s.input_tokens,
output: s.output_tokens,
cache_read: 0,
cache_write: 0,
total: s.input_tokens + s.output_tokens,
}
} else {
AnalyticsTokens {
input: data.tokens.input,
output: data.tokens.output,
cache_read: data.tokens.cache_read + data.tokens.cached_input,
cache_write: data.tokens.cache_write_5m + data.tokens.cache_write_1h,
total: data.tokens.all_input()
+ data.tokens.output
+ if s.provider == Provider::Gemini {
data.tokens.reasoning_output
} else {
0
},
}
},
cost: if s.cost_available {
Some(s.total_cost.unwrap_or(data.costs.total))
} else {
None
},
cost_available: s.cost_available,
cost_included: included,
cost_free: s.cost_is_free,
tools: s.tool_count,
tool_errors: s.provider.records_tool_outcomes().then_some(s.tool_errors),
tool_names: data.metrics.tools.clone(),
lines_added: data.metrics.lines_added,
lines_removed: data.metrics.lines_removed,
subagents: s.subagents.len(),
subagent_cost: s.subagents_cost,
by_day: s.costs_by_day.clone(),
by_hour: s.costs_by_hour.clone(),
tokens_by_day: data.tokens_by_day.clone(),
tokens_by_hour: data.tokens_by_hour.clone(),
writes: s.recent_writes.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::Remote;
fn row_of(s: Session, plan: Plan) -> AnalyticsSession {
let store = crate::cache::Store::new();
build(&[s], plan, &store)
.sessions
.into_iter()
.next()
.expect("one row in, one row out")
}
#[test]
fn a_provider_with_no_billable_usage_reports_null_cost() {
let mut s = Session::new(Provider::Cursor, "c".into());
s.cost_available = false;
s.total_cost = None;
let row = row_of(s, Plan::Retail);
assert!(!row.cost_available);
assert_eq!(row.cost, None);
assert!(!row.cost_included);
}
#[test]
fn a_bundled_session_is_marked_included_and_keeps_its_figures() {
let mut s = Session::new(Provider::Claude, "x".into());
s.total_cost = None;
s.costs_by_day
.insert("2026-08-11".into(), HashMap::from([("m".into(), 1.25)]));
let row = row_of(s, Plan::Max);
assert!(row.cost_included);
assert_eq!(row.cost, Some(0.0));
assert_eq!(row.by_day["2026-08-11"]["m"], 1.25);
}
#[test]
fn tool_errors_is_null_only_where_the_harness_cannot_say() {
let pi = row_of(Session::new(Provider::Pi, "p".into()), Plan::Retail);
assert_eq!(pi.tool_errors, None);
let mut claude = Session::new(Provider::Claude, "c".into());
claude.tool_errors = 3;
let claude = row_of(claude, Plan::Retail);
assert_eq!(claude.tool_errors, Some(3));
}
#[test]
fn a_remote_row_reports_what_the_wire_carried() {
let mut s = Session::new(Provider::Claude, "r".into());
s.remote = Some(Remote {
host: "buildbox".into(),
branch: Some("main".into()),
});
s.total_cost = Some(4.2);
s.input_tokens = 4_000;
s.output_tokens = 500;
s.costs_by_day
.insert("2026-08-11".into(), HashMap::from([("m".into(), 4.2)]));
let row = row_of(s, Plan::Retail);
assert_eq!(row.host.as_deref(), Some("buildbox"));
assert_eq!(row.branch.as_deref(), Some("main"));
assert_eq!(row.by_day["2026-08-11"]["m"], 4.2);
assert_eq!(row.cost, Some(4.2));
assert_eq!(row.tokens.total, 4_500);
assert!(row.tokens_by_day.is_empty());
}
}