mirror of
https://github.com/openai/codex.git
synced 2026-11-13 21:30:47 +00:00
Add account analytics data normalization to the TUI (#45740)
## What changed Add display models and adapters from typed backend responses for usage, credits, messages, plugins, and skills. Normalize reports into daily histories with grouping labels, units, and freshness timestamps. Preserve missing values, signed credit adjustments, and full daily totals when grouping usage by task start. Account for unassigned message counts as `Other`, merge duplicate dates, and fill unreported days only for credit reports. Group models contributing less than 1% of relative usage into `Other`. This adds the data preparation layer for dashboard integration; it is not yet connected to `/usage`. ## Testing Add unit tests covering missing versus explicit zero values, signed credits, enterprise labels and freshness, usage grouping, duplicate-date message remainders, and unsupported message groupings. GitOrigin-RevId: 931f46a9a6521b4cda2fc26051e7a305cbf6fa6c
This commit is contained in:
Generated
+1
@@ -4720,6 +4720,7 @@ dependencies = [
|
||||
"codex-app-server-client",
|
||||
"codex-app-server-protocol",
|
||||
"codex-arg0",
|
||||
"codex-backend-client",
|
||||
"codex-build-info",
|
||||
"codex-cloud-config",
|
||||
"codex-config",
|
||||
|
||||
@@ -32,6 +32,7 @@ codex-ansi-escape = { workspace = true }
|
||||
codex-app-server-client = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
codex-backend-client = { workspace = true }
|
||||
codex-install-context = { workspace = true }
|
||||
codex-cloud-config = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Account analytics data preparation, staged for the dashboard integration.
|
||||
|
||||
mod models;
|
||||
mod normalize;
|
||||
mod report_data;
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Private analytics display types, independent of the app-server wire protocol.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AccountAnalyticsReport {
|
||||
Usage,
|
||||
Credits,
|
||||
Messages,
|
||||
Plugins,
|
||||
Skills,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AccountAnalyticsGrouping {
|
||||
Feature,
|
||||
Model,
|
||||
Surface,
|
||||
TaskStart,
|
||||
Speed,
|
||||
Reasoning,
|
||||
TokenType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum AccountAnalyticsUnit {
|
||||
RelativeUsage,
|
||||
Credits,
|
||||
Count,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct AccountAnalyticsHistory {
|
||||
pub(crate) unit: AccountAnalyticsUnit,
|
||||
/// Source freshness in Unix seconds, when provided. Not a completeness watermark.
|
||||
pub(crate) updated_at: Option<i64>,
|
||||
pub(crate) data: Vec<AccountAnalyticsDay>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct AccountAnalyticsDay {
|
||||
/// Source reporting date; display formatting belongs to the UI.
|
||||
pub(crate) date: chrono::NaiveDate,
|
||||
/// Full daily denominator, including non-task usage when grouped by task start.
|
||||
pub(crate) total: f64,
|
||||
pub(crate) values: Vec<AccountAnalyticsValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct AccountAnalyticsValue {
|
||||
pub(crate) key: String,
|
||||
pub(crate) label: String,
|
||||
pub(crate) value: f64,
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Converts backend reports into display data while preserving missing values and full denominators.
|
||||
|
||||
use super::models::AccountAnalyticsGrouping as Grouping;
|
||||
use super::models::AccountAnalyticsReport as Report;
|
||||
use super::models::*;
|
||||
use super::report_data::AnalyticsData;
|
||||
use chrono::DateTime;
|
||||
use chrono::NaiveDate;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(super) fn history(
|
||||
response: AnalyticsData,
|
||||
report: Report,
|
||||
grouping: Grouping,
|
||||
start: NaiveDate,
|
||||
end: NaiveDate,
|
||||
) -> Result<Option<AccountAnalyticsHistory>, String> {
|
||||
if report == Report::Messages {
|
||||
match grouping {
|
||||
Grouping::Model | Grouping::Surface => {}
|
||||
Grouping::Feature
|
||||
| Grouping::TaskStart
|
||||
| Grouping::Speed
|
||||
| Grouping::Reasoning
|
||||
| Grouping::TokenType => {
|
||||
return Err(String::from("Unsupported message count grouping."));
|
||||
}
|
||||
}
|
||||
}
|
||||
let series = response.series.as_ref();
|
||||
let mut days: BTreeMap<NaiveDate, (f64, BTreeMap<String, f64>)> = BTreeMap::new();
|
||||
for record in response.data {
|
||||
let date =
|
||||
NaiveDate::parse_from_str(record.date.get(..10).unwrap_or(&record.date), "%Y-%m-%d")
|
||||
.map_err(|_| String::from("Analytics returned an invalid date."))?;
|
||||
if date < start || date > end {
|
||||
continue;
|
||||
}
|
||||
let (total, values) = days.entry(date).or_default();
|
||||
match report {
|
||||
Report::Usage => {
|
||||
let daily = match grouping {
|
||||
Grouping::Surface => record.product_surface_usage_values,
|
||||
Grouping::Model => record
|
||||
.models
|
||||
.map(|models| -> Result<_, String> {
|
||||
let mut values = BTreeMap::new();
|
||||
for model in models {
|
||||
let amount =
|
||||
model.credits.ok_or("Plan usage amount was not reported.")?;
|
||||
*values.entry(model.model).or_insert(/*default*/ 0.0) += amount;
|
||||
}
|
||||
Ok(values)
|
||||
})
|
||||
.transpose()?,
|
||||
_ => None,
|
||||
};
|
||||
if let Some(daily) = daily {
|
||||
for (key, amount) in daily {
|
||||
if !amount.is_finite() || amount < 0.0 {
|
||||
return Err(String::from("Analytics returned an invalid amount."));
|
||||
}
|
||||
let key = if grouping == Grouping::Surface {
|
||||
if key.starts_with("work_") {
|
||||
"work"
|
||||
} else {
|
||||
"codex"
|
||||
}
|
||||
.to_string()
|
||||
} else if key.eq_ignore_ascii_case("other") {
|
||||
"other".to_string()
|
||||
} else {
|
||||
key
|
||||
};
|
||||
*total += amount;
|
||||
*values.entry(key).or_default() += amount;
|
||||
}
|
||||
} else if let Some(attribution) = record.attribution {
|
||||
for entry in attribution {
|
||||
if !entry.value.is_finite() || entry.value < 0.0 {
|
||||
return Err(String::from("Analytics returned an invalid amount."));
|
||||
}
|
||||
*total += entry.value;
|
||||
if grouping == Grouping::TaskStart
|
||||
&& entry.thread_source.as_deref() != Some("user")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let key = match grouping {
|
||||
Grouping::Feature => entry.thread_source,
|
||||
Grouping::Model => entry.model,
|
||||
Grouping::Surface => entry.surface,
|
||||
Grouping::TaskStart => {
|
||||
entry.turn_trigger.map(|trigger| format!("start-{trigger}"))
|
||||
}
|
||||
Grouping::Speed | Grouping::Reasoning | Grouping::TokenType => {
|
||||
return Err(String::from("unsupported analytics grouping"));
|
||||
}
|
||||
}
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
*values.entry(key).or_default() += entry.value;
|
||||
}
|
||||
} else {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
Report::Credits => {
|
||||
if series.is_some() {
|
||||
let Some(credits) = record.values else {
|
||||
return Ok(None);
|
||||
};
|
||||
for (key, amount) in credits {
|
||||
*values.entry(key).or_default() += amount;
|
||||
*total += amount;
|
||||
}
|
||||
} else if matches!(grouping, Grouping::Model | Grouping::Speed) {
|
||||
let Some(models) = record.models else {
|
||||
return Ok(None);
|
||||
};
|
||||
for model in models {
|
||||
let amount = model.on_demand_credits.unwrap_or(/*default*/ 0.0);
|
||||
let key = if grouping == Grouping::Speed {
|
||||
model.speed.unwrap_or_else(|| "unknown".to_string())
|
||||
} else {
|
||||
model.model
|
||||
};
|
||||
*values.entry(key).or_default() += amount;
|
||||
*total += amount;
|
||||
}
|
||||
} else if let Some(premium) = record.premium_usage_values {
|
||||
for (surface, amount) in premium.credit_usage_credits {
|
||||
if !amount.is_finite() {
|
||||
return Err(String::from("Analytics returned an invalid amount."));
|
||||
}
|
||||
*values.entry(surface).or_default() += amount;
|
||||
*total += amount;
|
||||
}
|
||||
} else if let (Some(amount), Some(surface)) =
|
||||
(record.credit_amount, record.product_surface)
|
||||
{
|
||||
*values.entry(surface).or_default() += amount;
|
||||
*total += amount;
|
||||
} else {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Report::Messages => {
|
||||
let authoritative = record
|
||||
.totals
|
||||
.ok_or_else(|| String::from("Message count is unavailable."))?
|
||||
.turns;
|
||||
if !authoritative.is_finite() || authoritative < 0.0 {
|
||||
return Err(String::from("Analytics returned an invalid amount."));
|
||||
}
|
||||
let mut record_values = BTreeMap::<String, f64>::new();
|
||||
if grouping == Grouping::Model {
|
||||
for model in record.models.unwrap_or_default() {
|
||||
let key = if model.model.eq_ignore_ascii_case("codex-auto-review")
|
||||
|| model.model.eq_ignore_ascii_case("other")
|
||||
{
|
||||
"other".to_string()
|
||||
} else {
|
||||
model.model
|
||||
};
|
||||
let count = model
|
||||
.turns
|
||||
.ok_or_else(|| String::from("Turn count is unavailable."))?;
|
||||
if !count.is_finite() || count < 0.0 {
|
||||
return Err(String::from("Analytics returned an invalid amount."));
|
||||
}
|
||||
*record_values.entry(key).or_default() += count;
|
||||
}
|
||||
} else {
|
||||
for client in record.clients.unwrap_or_default() {
|
||||
if !client.turns.is_finite() || client.turns < 0.0 {
|
||||
return Err(String::from("Analytics returned an invalid amount."));
|
||||
}
|
||||
let key = match client.client_id.as_str() {
|
||||
"CODEX_CLI" => "cli",
|
||||
"CODEX_IDE_VSCODE" => "vscode",
|
||||
"CODEX_WEB" => "web",
|
||||
"CODEX_DESKTOP_APP" | "CODEX_WORK_DESKTOP" => "desktop_app",
|
||||
"CODEX_WORK_WEB" => "work_web",
|
||||
"CODEX_WORK_MOBILE" => "mobile",
|
||||
"CODEX_GITHUB" => "github_code_review",
|
||||
_ => "other",
|
||||
};
|
||||
*record_values.entry(key.to_string()).or_default() += client.turns;
|
||||
}
|
||||
}
|
||||
let remainder =
|
||||
(authoritative - record_values.values().sum::<f64>()).max(/*other*/ 0.0);
|
||||
if remainder > 0.0 {
|
||||
*record_values.entry("other".to_string()).or_default() += remainder;
|
||||
}
|
||||
for (key, count) in record_values {
|
||||
*values.entry(key).or_default() += count;
|
||||
}
|
||||
*total += authoritative;
|
||||
}
|
||||
Report::Plugins | Report::Skills => {
|
||||
let overviews = if report == Report::Skills {
|
||||
record.skill_usage_overviews
|
||||
} else {
|
||||
record.plugin_usage_overviews
|
||||
}
|
||||
.ok_or_else(|| String::from("Tool activity is unavailable."))?;
|
||||
for tool in overviews {
|
||||
if !tool.invocation_counts.is_finite() || tool.invocation_counts < 0.0 {
|
||||
return Err(String::from("Analytics returned an invalid amount."));
|
||||
}
|
||||
*values.entry(tool.display_name).or_default() += tool.invocation_counts;
|
||||
*total += tool.invocation_counts;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !total.is_finite()
|
||||
|| values
|
||||
.values()
|
||||
.any(|value| !value.is_finite() || (report != Report::Credits && *value < 0.0))
|
||||
{
|
||||
return Err(String::from("Analytics returned an invalid amount."));
|
||||
}
|
||||
}
|
||||
if report == Report::Usage
|
||||
&& grouping == Grouping::Model
|
||||
&& response.units.as_deref() != Some("credits")
|
||||
{
|
||||
let mut totals = BTreeMap::<String, f64>::new();
|
||||
for (_, values) in days.values() {
|
||||
for (key, value) in values {
|
||||
*totals.entry(key.clone()).or_default() += value;
|
||||
}
|
||||
}
|
||||
let total: f64 = totals.values().sum();
|
||||
for (_, values) in days.values_mut() {
|
||||
let mut other = 0.0;
|
||||
values.retain(|key, value| {
|
||||
let amount = totals[key];
|
||||
if key.eq_ignore_ascii_case("other") || (amount > 0.0 && amount < total * 0.01) {
|
||||
other += *value;
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
if other > 0.0 {
|
||||
values.insert("other".to_string(), other);
|
||||
}
|
||||
}
|
||||
}
|
||||
if report == Report::Credits {
|
||||
for date in start.iter_days().take_while(|date| *date <= end) {
|
||||
days.entry(date).or_default();
|
||||
}
|
||||
}
|
||||
Ok(Some(AccountAnalyticsHistory {
|
||||
unit: match report {
|
||||
Report::Usage if response.units.as_deref() == Some("credits") => {
|
||||
AccountAnalyticsUnit::Credits
|
||||
}
|
||||
Report::Usage => AccountAnalyticsUnit::RelativeUsage,
|
||||
Report::Credits => AccountAnalyticsUnit::Credits,
|
||||
Report::Messages | Report::Plugins | Report::Skills => AccountAnalyticsUnit::Count,
|
||||
},
|
||||
updated_at: response
|
||||
.data_freshness_ts
|
||||
.as_deref()
|
||||
.and_then(|timestamp| DateTime::parse_from_rfc3339(timestamp).ok())
|
||||
.map(|timestamp| timestamp.timestamp()),
|
||||
data: days
|
||||
.into_iter()
|
||||
.map(|(date, (total, values))| AccountAnalyticsDay {
|
||||
date,
|
||||
total,
|
||||
values: values
|
||||
.into_iter()
|
||||
.map(|(key, value)| AccountAnalyticsValue {
|
||||
label: series
|
||||
.and_then(|series| series.iter().find(|series| series.key == key))
|
||||
.map(|series| series.label.clone())
|
||||
.unwrap_or_else(|| label(&key).to_string()),
|
||||
key,
|
||||
value,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn label(key: &str) -> &str {
|
||||
match key {
|
||||
"user" => "Tasks",
|
||||
"subagent" => "Subagents",
|
||||
"image_generation" => "Image generation",
|
||||
"automation" => "Automations",
|
||||
"guardian_review" => "Guardian review",
|
||||
"automated_review" => "Auto review",
|
||||
"agent_identity" => "Workspace agents",
|
||||
"memory_consolidation" => "Memory consolidation",
|
||||
"cli" => "CLI",
|
||||
"desktop_app" => "Desktop app",
|
||||
"vscode" => "VS Code",
|
||||
"web" => "Web",
|
||||
"work_web" => "Work web",
|
||||
"work_desktop" => "Work desktop",
|
||||
"work_mobile" => "Work mobile",
|
||||
"mobile" => "Mobile",
|
||||
"slack" => "Slack",
|
||||
"linear" => "Linear",
|
||||
"jetbrains" => "JetBrains",
|
||||
"sdk" => "SDK",
|
||||
"exec" => "Exec",
|
||||
"github" => "GitHub",
|
||||
"codex" => "Codex",
|
||||
"work" => "Work",
|
||||
"code_review" | "github_code_review" => "Code review",
|
||||
"start-user" | "start-composer" => "User messages",
|
||||
"start-goal" => "Goals",
|
||||
"start-composer_queue" => "Queued messages",
|
||||
"start-composer_queue_run_now" => "Queued messages run now",
|
||||
"start-automation_cron_scheduled" => "Scheduled automations",
|
||||
"start-automation_cron_run_now" => "Automations run now",
|
||||
"start-automation_heartbeat_scheduled" => "Scheduled follow-ups",
|
||||
"start-automation_heartbeat_run_now" => "Follow-ups run now",
|
||||
"start-app_tool_create_thread" => "Agent-created tasks",
|
||||
"start-app_tool_send_message" => "Agent follow-ups",
|
||||
"unknown" | "start-unknown" => "Unknown",
|
||||
"fast" => "Fast",
|
||||
"standard" => "Standard",
|
||||
"other" | "start-other" => "Other",
|
||||
_ => key,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "normalize_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Regression coverage for daily report normalization and billing semantics.
|
||||
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn task_start_keeps_the_full_denominator_and_missing_attribution_is_unavailable() {
|
||||
let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap();
|
||||
let response = serde_json::from_value(json!({"data": [{"date": "2026-01-09", "attribution": [
|
||||
{"thread_source": "user", "turn_trigger": "composer", "value": 30},
|
||||
{"thread_source": "subagent", "value": 70}
|
||||
]}]}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
history(response, Report::Usage, Grouping::TaskStart, date, date).unwrap(),
|
||||
Some(AccountAnalyticsHistory {
|
||||
unit: AccountAnalyticsUnit::RelativeUsage,
|
||||
updated_at: None,
|
||||
data: vec![AccountAnalyticsDay {
|
||||
date: "2026-01-09".parse().unwrap(),
|
||||
total: 100.0,
|
||||
values: vec![AccountAnalyticsValue {
|
||||
key: "start-composer".to_string(),
|
||||
label: "User messages".to_string(),
|
||||
value: 30.0
|
||||
}],
|
||||
}],
|
||||
})
|
||||
);
|
||||
let response = serde_json::from_value(json!({"data": [{"date": "2026-01-09"}]})).unwrap();
|
||||
assert_eq!(
|
||||
history(response, Report::Usage, Grouping::Feature, date, date).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_credits_keep_daily_activity_when_the_period_nets_to_zero() {
|
||||
let start = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 8).unwrap();
|
||||
let end = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap();
|
||||
let response = serde_json::from_value(json!({"data": [
|
||||
{"date": "2026-01-08", "product_surface": "cli", "credit_amount": 0.004},
|
||||
{"date": "2026-01-09", "product_surface": "desktop_app", "credit_amount": -0.004}
|
||||
]}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
history(response, Report::Credits, Grouping::Surface, start, end).unwrap(),
|
||||
Some(AccountAnalyticsHistory {
|
||||
unit: AccountAnalyticsUnit::Credits,
|
||||
updated_at: None,
|
||||
data: [
|
||||
("2026-01-08", 0.004, "cli", "CLI"),
|
||||
("2026-01-09", -0.004, "desktop_app", "Desktop app")
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(date, amount, key, label)| AccountAnalyticsDay {
|
||||
date: date.parse().unwrap(),
|
||||
total: amount,
|
||||
values: vec![AccountAnalyticsValue {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
value: amount
|
||||
}],
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_surfaces_include_both_desktop_clients_and_unmapped_counts() {
|
||||
let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap();
|
||||
let response = serde_json::from_value(
|
||||
json!({"data": [{"date": "2026-01-09", "totals": {"turns": 10}, "clients": [
|
||||
{"client_id": "CODEX_DESKTOP_APP", "turns": 2},
|
||||
{"client_id": "CODEX_WORK_DESKTOP", "turns": 3},
|
||||
{"client_id": "new_client", "turns": 1}
|
||||
]}]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
history(response, Report::Messages, Grouping::Surface, date, date)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.data,
|
||||
vec![AccountAnalyticsDay {
|
||||
date: "2026-01-09".parse().unwrap(),
|
||||
total: 10.0,
|
||||
values: vec![
|
||||
AccountAnalyticsValue {
|
||||
key: "desktop_app".to_string(),
|
||||
label: "Desktop app".to_string(),
|
||||
value: 5.0
|
||||
},
|
||||
AccountAnalyticsValue {
|
||||
key: "other".to_string(),
|
||||
label: "Other".to_string(),
|
||||
value: 5.0
|
||||
},
|
||||
],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enterprise_credit_series_keep_labels_signed_values_and_freshness() {
|
||||
let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap();
|
||||
let response = serde_json::from_value(json!({
|
||||
"series": [{"key": "medium", "label": "Medium", "total": 12.5}, {"key": "high", "label": "High", "total": -0.004}],
|
||||
"data_freshness_ts": "2026-01-09T10:00:00Z",
|
||||
"data": [{"date": "2026-01-09", "values": {"medium": 12.5, "high": -0.004}}]
|
||||
})).unwrap();
|
||||
assert_eq!(
|
||||
history(response, Report::Credits, Grouping::Reasoning, date, date).unwrap(),
|
||||
Some(AccountAnalyticsHistory {
|
||||
unit: AccountAnalyticsUnit::Credits,
|
||||
updated_at: Some(1767952800),
|
||||
data: vec![AccountAnalyticsDay {
|
||||
date,
|
||||
total: 12.496,
|
||||
values: vec![
|
||||
AccountAnalyticsValue {
|
||||
key: "high".into(),
|
||||
label: "High".into(),
|
||||
value: -0.004
|
||||
},
|
||||
AccountAnalyticsValue {
|
||||
key: "medium".into(),
|
||||
label: "Medium".into(),
|
||||
value: 12.5
|
||||
}
|
||||
],
|
||||
}],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credit_timestamps_and_legacy_model_usage_follow_app_fallbacks() {
|
||||
let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap();
|
||||
let credits = serde_json::from_value(json!({"data": [{"date": "2026-01-09T05:00:00Z", "product_surface": "cli", "credit_amount": 12.5}]})).unwrap();
|
||||
let usage = serde_json::from_value(json!({"data": [{"date": "2026-01-09", "models": [{"model": "model-a", "credits": 12.5}]}]})).unwrap();
|
||||
let credits = history(credits, Report::Credits, Grouping::Surface, date, date)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let usage = history(usage, Report::Usage, Grouping::Model, date, date)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
credits.data[0].total,
|
||||
usage.data[0].total,
|
||||
usage.data[0].values[0].key.as_str()
|
||||
),
|
||||
(12.5, 12.5, "model-a")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_reports_preserve_explicit_zero_and_omit_unreported_days() {
|
||||
let start = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 8).unwrap();
|
||||
let end = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 10).unwrap();
|
||||
for (report, field) in [
|
||||
(Report::Messages, "totals"),
|
||||
(Report::Plugins, "plugin_usage_overviews"),
|
||||
(Report::Skills, "skill_usage_overviews"),
|
||||
] {
|
||||
let value = if report == Report::Messages {
|
||||
json!({"turns": 0})
|
||||
} else {
|
||||
json!([])
|
||||
};
|
||||
let response =
|
||||
serde_json::from_value(json!({"data": [{"date": "2026-01-08", (field): value}]}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
history(response, report, Grouping::Surface, start, end).unwrap(),
|
||||
Some(AccountAnalyticsHistory {
|
||||
unit: AccountAnalyticsUnit::Count,
|
||||
updated_at: None,
|
||||
data: vec![AccountAnalyticsDay {
|
||||
date: start,
|
||||
total: 0.0,
|
||||
values: Vec::new()
|
||||
}],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumer_plan_prefers_daily_products_and_models_over_attribution() {
|
||||
let date = "2026-09-01".parse().unwrap();
|
||||
let response: AnalyticsData = serde_json::from_value(json!({"units":"relative", "data":[{
|
||||
"date":"2026-09-01",
|
||||
"product_surface_usage_values":{"work_desktop":12,"work_web":8,"cli":30,"desktop_app":50},
|
||||
"models":[{"model":"major","credits":98.5},{"model":"one-percent","credits":1},{"model":"tiny","credits":0.3},{"model":"OTHER","credits":0.2}],
|
||||
"attribution":[{"surface":"wrong","model":"wrong","value":999}]
|
||||
}]})).unwrap();
|
||||
for (grouping, expected) in [
|
||||
(
|
||||
Grouping::Surface,
|
||||
vec![("codex", "Codex", 80.0), ("work", "Work", 20.0)],
|
||||
),
|
||||
(
|
||||
Grouping::Model,
|
||||
vec![
|
||||
("major", "major", 98.5),
|
||||
("one-percent", "one-percent", 1.0),
|
||||
("other", "Other", 0.5),
|
||||
],
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
history(response.clone(), Report::Usage, grouping, date, date).unwrap(),
|
||||
Some(AccountAnalyticsHistory {
|
||||
unit: AccountAnalyticsUnit::RelativeUsage,
|
||||
updated_at: None,
|
||||
data: vec![AccountAnalyticsDay {
|
||||
date,
|
||||
total: 100.0,
|
||||
values: expected
|
||||
.into_iter()
|
||||
.map(|(key, label, value)| AccountAnalyticsValue {
|
||||
key: key.into(),
|
||||
label: label.into(),
|
||||
value
|
||||
})
|
||||
.collect()
|
||||
}],
|
||||
})
|
||||
);
|
||||
}
|
||||
let mut credits = response;
|
||||
credits.units = Some("credits".into());
|
||||
let model = history(credits, Report::Usage, Grouping::Model, date, date)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(model.unit, model.data[0].values.len()),
|
||||
(AccountAnalyticsUnit::Credits, 4)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_message_dates_preserve_each_records_other_remainder() {
|
||||
let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap();
|
||||
for grouping in [Grouping::Model, Grouping::Surface] {
|
||||
let response = serde_json::from_value(json!({"data": [
|
||||
{"date": "2026-01-09", "totals": {"turns": 10}, "models": [{"model": "example", "turns": 8}], "clients": [{"client_id": "CODEX_CLI", "turns": 8}]},
|
||||
{"date": "2026-01-09", "totals": {"turns": 10}, "models": [{"model": "example", "turns": 8}], "clients": [{"client_id": "CODEX_CLI", "turns": 8}]}
|
||||
]})).unwrap();
|
||||
let (key, label) = if grouping == Grouping::Model {
|
||||
("example", "example")
|
||||
} else {
|
||||
("cli", "CLI")
|
||||
};
|
||||
assert_eq!(
|
||||
history(response, Report::Messages, grouping, date, date).unwrap(),
|
||||
Some(AccountAnalyticsHistory {
|
||||
unit: AccountAnalyticsUnit::Count,
|
||||
updated_at: None,
|
||||
data: vec![AccountAnalyticsDay {
|
||||
date,
|
||||
total: 20.0,
|
||||
values: vec![
|
||||
AccountAnalyticsValue {
|
||||
key: key.into(),
|
||||
label: label.into(),
|
||||
value: 16.0
|
||||
},
|
||||
AccountAnalyticsValue {
|
||||
key: "other".into(),
|
||||
label: "Other".into(),
|
||||
value: 4.0
|
||||
},
|
||||
]
|
||||
}],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_message_groupings_fail_even_without_records() {
|
||||
let date = NaiveDate::from_ymd_opt(/*year*/ 2026, /*month*/ 1, /*day*/ 9).unwrap();
|
||||
for grouping in [
|
||||
Grouping::Feature,
|
||||
Grouping::TaskStart,
|
||||
Grouping::Speed,
|
||||
Grouping::Reasoning,
|
||||
Grouping::TokenType,
|
||||
] {
|
||||
for data in [
|
||||
json!([]),
|
||||
json!([{"date": "2026-01-09", "totals": {"turns": 10}, "clients": []}]),
|
||||
] {
|
||||
let response = serde_json::from_value(json!({"data": data})).unwrap();
|
||||
assert_eq!(
|
||||
history(response, Report::Messages, grouping, date, date),
|
||||
Err("Unsupported message count grouping.".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//! Projects endpoint-specific backend models into inputs for display normalization.
|
||||
//! This representation is never used to deserialize HTTP responses.
|
||||
//! Optional fields select the inputs used by each report; missing backend values stay missing.
|
||||
|
||||
use codex_backend_client::AnalyticsResponse;
|
||||
use codex_backend_client::analytics_models as backend;
|
||||
|
||||
/// Report values projected for normalization after endpoint-specific decoding.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[cfg_attr(test, derive(serde::Deserialize))]
|
||||
pub(super) struct AnalyticsData {
|
||||
pub(super) data: Vec<AnalyticsRecord>,
|
||||
pub(super) units: Option<String>,
|
||||
pub(super) data_freshness_ts: Option<String>,
|
||||
pub(super) series: Option<Vec<AnalyticsSeries>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[cfg_attr(test, derive(serde::Deserialize))]
|
||||
pub(super) struct AnalyticsSeries {
|
||||
pub(super) key: String,
|
||||
pub(super) label: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[cfg_attr(test, derive(serde::Deserialize))]
|
||||
pub(super) struct AnalyticsRecord {
|
||||
pub(super) date: String,
|
||||
pub(super) values: Option<std::collections::BTreeMap<String, f64>>,
|
||||
pub(super) product_surface_usage_values: Option<std::collections::BTreeMap<String, f64>>,
|
||||
pub(super) premium_usage_values: Option<PremiumUsage>,
|
||||
pub(super) attribution: Option<Vec<UsageAttribution>>,
|
||||
pub(super) credit_amount: Option<f64>,
|
||||
pub(super) product_surface: Option<String>,
|
||||
pub(super) totals: Option<AnalyticsCount>,
|
||||
pub(super) models: Option<Vec<AnalyticsModel>>,
|
||||
pub(super) groups: Option<Vec<AnalyticsTokenGroup>>,
|
||||
pub(super) clients: Option<Vec<AnalyticsClient>>,
|
||||
pub(super) plugin_usage_overviews: Option<Vec<AnalyticsTool>>,
|
||||
pub(super) skill_usage_overviews: Option<Vec<AnalyticsTool>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[cfg_attr(test, derive(serde::Deserialize))]
|
||||
pub(super) struct PremiumUsage {
|
||||
pub(super) credit_usage_credits: std::collections::BTreeMap<String, f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[cfg_attr(test, derive(serde::Deserialize))]
|
||||
pub(super) struct UsageAttribution {
|
||||
pub(super) thread_source: Option<String>,
|
||||
pub(super) turn_trigger: Option<String>,
|
||||
pub(super) model: Option<String>,
|
||||
pub(super) surface: Option<String>,
|
||||
pub(super) value: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[cfg_attr(test, derive(serde::Deserialize))]
|
||||
pub(super) struct AnalyticsCount {
|
||||
pub(super) turns: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[cfg_attr(test, derive(serde::Deserialize))]
|
||||
pub(super) struct AnalyticsModel {
|
||||
pub(super) model: String,
|
||||
pub(super) credits: Option<f64>,
|
||||
pub(super) uncached_text_input_tokens: Option<f64>,
|
||||
pub(super) cached_text_input_tokens: Option<f64>,
|
||||
pub(super) text_output_tokens: Option<f64>,
|
||||
pub(super) text_total_tokens: Option<f64>,
|
||||
pub(super) turns: Option<f64>,
|
||||
pub(super) on_demand_credits: Option<f64>,
|
||||
pub(super) speed: Option<String>,
|
||||
}
|
||||
|
||||
/// Enterprise model groups carry text token counts independently of billing amounts.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[cfg_attr(test, derive(serde::Deserialize))]
|
||||
pub(super) struct AnalyticsTokenGroup {
|
||||
pub(super) dimensions: std::collections::BTreeMap<String, String>,
|
||||
#[cfg_attr(test, serde(default))]
|
||||
pub(super) is_other: bool,
|
||||
pub(super) uncached_text_input_tokens: Option<f64>,
|
||||
pub(super) cached_text_input_tokens: Option<f64>,
|
||||
pub(super) text_output_tokens: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[cfg_attr(test, derive(serde::Deserialize))]
|
||||
pub(super) struct AnalyticsClient {
|
||||
pub(super) client_id: String,
|
||||
pub(super) turns: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[cfg_attr(test, derive(serde::Deserialize))]
|
||||
pub(super) struct AnalyticsTool {
|
||||
pub(super) display_name: String,
|
||||
pub(super) invocation_counts: f64,
|
||||
}
|
||||
|
||||
impl From<AnalyticsResponse> for AnalyticsData {
|
||||
fn from(response: AnalyticsResponse) -> Self {
|
||||
match response {
|
||||
AnalyticsResponse::Usage(response) => Self {
|
||||
units: response.units,
|
||||
data_freshness_ts: response.data_freshness_ts,
|
||||
data: response
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|record| AnalyticsRecord {
|
||||
date: record.date,
|
||||
product_surface_usage_values: Some(record.product_surface_usage_values),
|
||||
premium_usage_values: record.premium_usage_values.map(|value| {
|
||||
PremiumUsage {
|
||||
credit_usage_credits: value.credit_usage_credits,
|
||||
}
|
||||
}),
|
||||
attribution: record.attribution.map(|values| {
|
||||
values
|
||||
.into_iter()
|
||||
.map(|value| UsageAttribution {
|
||||
thread_source: Some(value.thread_source),
|
||||
turn_trigger: Some(value.turn_trigger),
|
||||
model: Some(value.model),
|
||||
surface: Some(value.surface),
|
||||
value: value.value,
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
models: record
|
||||
.models
|
||||
.map(|values| values.into_iter().map(AnalyticsModel::from).collect()),
|
||||
groups: record.groups.map(|values| {
|
||||
values
|
||||
.into_iter()
|
||||
.map(|value| AnalyticsTokenGroup {
|
||||
dimensions: value.dimensions,
|
||||
is_other: value.is_other.unwrap_or(/*default*/ false),
|
||||
uncached_text_input_tokens: value
|
||||
.uncached_text_input_tokens
|
||||
.map(|count| count as f64),
|
||||
cached_text_input_tokens: value
|
||||
.cached_text_input_tokens
|
||||
.map(|count| count as f64),
|
||||
text_output_tokens: value
|
||||
.text_output_tokens
|
||||
.map(|count| count as f64),
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
},
|
||||
AnalyticsResponse::Credits(response) => Self {
|
||||
data: response
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|record| AnalyticsRecord {
|
||||
date: record.date,
|
||||
credit_amount: Some(record.credit_amount),
|
||||
product_surface: Some(record.product_surface),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
},
|
||||
AnalyticsResponse::EnterpriseCredits(response) => Self {
|
||||
data_freshness_ts: response.data_freshness_ts,
|
||||
series: Some(
|
||||
response
|
||||
.series
|
||||
.into_iter()
|
||||
.map(|value| AnalyticsSeries {
|
||||
key: value.key,
|
||||
label: value.label,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
data: response
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|record| AnalyticsRecord {
|
||||
date: record.date,
|
||||
values: Some(record.values),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
},
|
||||
AnalyticsResponse::Messages(response) => Self {
|
||||
data: response
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|record| AnalyticsRecord {
|
||||
date: record.date,
|
||||
totals: Some(AnalyticsCount {
|
||||
turns: record.totals.turns as f64,
|
||||
}),
|
||||
clients: Some(
|
||||
record
|
||||
.clients
|
||||
.into_iter()
|
||||
.map(|value| AnalyticsClient {
|
||||
client_id: value.client_id,
|
||||
turns: value.turns as f64,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
models: record
|
||||
.models
|
||||
.map(|values| values.into_iter().map(AnalyticsModel::from).collect()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
},
|
||||
AnalyticsResponse::Plugins(response) => Self {
|
||||
data_freshness_ts: response.data_freshness_ts,
|
||||
data: response
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|record| AnalyticsRecord {
|
||||
date: record.date,
|
||||
plugin_usage_overviews: Some(
|
||||
record
|
||||
.plugin_usage_overviews
|
||||
.into_iter()
|
||||
.map(|value| AnalyticsTool {
|
||||
display_name: value.display_name,
|
||||
invocation_counts: value.invocation_counts as f64,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
},
|
||||
AnalyticsResponse::Skills(response) => Self {
|
||||
data_freshness_ts: response.data_freshness_ts,
|
||||
data: response
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|record| AnalyticsRecord {
|
||||
date: record.date,
|
||||
skill_usage_overviews: Some(
|
||||
record
|
||||
.skill_usage_overviews
|
||||
.into_iter()
|
||||
.map(|value| AnalyticsTool {
|
||||
display_name: value.display_name,
|
||||
invocation_counts: value.invocation_counts as f64,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<backend::ModelUsage> for AnalyticsModel {
|
||||
fn from(value: backend::ModelUsage) -> Self {
|
||||
Self {
|
||||
model: value.model,
|
||||
credits: Some(value.credits),
|
||||
uncached_text_input_tokens: value.uncached_text_input_tokens.map(|count| count as f64),
|
||||
cached_text_input_tokens: value.cached_text_input_tokens.map(|count| count as f64),
|
||||
text_output_tokens: value.text_output_tokens.map(|count| count as f64),
|
||||
text_total_tokens: value.text_total_tokens.map(|count| count as f64),
|
||||
turns: value.turns.map(|count| count as f64),
|
||||
on_demand_credits: value.on_demand_credits,
|
||||
speed: value.speed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "report_data_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,84 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn typed_usage_preserves_missing_counts_and_supplied_zero() {
|
||||
let input = backend::DailyProductSurfaceUsageResponse {
|
||||
data: vec![backend::DailyProductSurfaceUsage {
|
||||
date: "2026-01-09".into(),
|
||||
models: Some(vec![backend::ModelUsage {
|
||||
model: "example".into(),
|
||||
credits: 1.5,
|
||||
cached_text_input_tokens: Some(0),
|
||||
text_output_tokens: Some(42),
|
||||
..Default::default()
|
||||
}]),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let actual = AnalyticsData::from(AnalyticsResponse::Usage(input));
|
||||
let model = &actual.data[0].models.as_ref().unwrap()[0];
|
||||
assert_eq!(
|
||||
(
|
||||
model.credits,
|
||||
model.uncached_text_input_tokens,
|
||||
model.cached_text_input_tokens,
|
||||
model.text_output_tokens
|
||||
),
|
||||
(Some(1.5), None, Some(0.0), Some(42.0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_message_records_keep_independent_daily_remainders() {
|
||||
use super::super::models::*;
|
||||
let date = "2026-01-09".parse().unwrap();
|
||||
let row = backend::DailyWorkspaceUsageCount {
|
||||
date: "2026-01-09".into(),
|
||||
totals: backend::WorkspaceUsageCount {
|
||||
turns: 10,
|
||||
..Default::default()
|
||||
},
|
||||
clients: vec![backend::ClientWorkspaceUsageCount {
|
||||
client_id: "CODEX_CLI".into(),
|
||||
turns: 8,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let response = AnalyticsResponse::Messages(backend::DailyWorkspaceUsageCountResponse {
|
||||
data: vec![row.clone(), row],
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(
|
||||
super::super::normalize::history(
|
||||
response.into(),
|
||||
AccountAnalyticsReport::Messages,
|
||||
AccountAnalyticsGrouping::Surface,
|
||||
date,
|
||||
date
|
||||
)
|
||||
.unwrap(),
|
||||
Some(AccountAnalyticsHistory {
|
||||
unit: AccountAnalyticsUnit::Count,
|
||||
updated_at: None,
|
||||
data: vec![AccountAnalyticsDay {
|
||||
date,
|
||||
total: 20.0,
|
||||
values: vec![
|
||||
AccountAnalyticsValue {
|
||||
key: "cli".into(),
|
||||
label: "CLI".into(),
|
||||
value: 16.0
|
||||
},
|
||||
AccountAnalyticsValue {
|
||||
key: "other".into(),
|
||||
label: "Other".into(),
|
||||
value: 4.0
|
||||
},
|
||||
]
|
||||
}],
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -102,6 +102,12 @@ pub(crate) use codex_app_server_client::legacy_core;
|
||||
pub(crate) use worktree_startup::ManagedTuiWorktree;
|
||||
|
||||
mod additional_dirs;
|
||||
// Removed when the final stack layer connects Analytics to /usage.
|
||||
#[expect(
|
||||
dead_code,
|
||||
reason = "analytics is being introduced in independently tested stack layers"
|
||||
)]
|
||||
mod analytics;
|
||||
mod app;
|
||||
mod app_backtrack;
|
||||
mod app_command;
|
||||
|
||||
Reference in New Issue
Block a user