fix: align dashboard charts with customer billing and history coverage

This commit is contained in:
elky
2026-10-09 21:53:43 +08:00
parent 63520c8c27
commit 6b6d5b3a85
19 changed files with 1561 additions and 153 deletions
@@ -205,7 +205,7 @@ CREATE TRIGGER overview_usage_delete_attribution BEFORE DELETE ON public.usage
-- procurement cost remains in actual_total_cost_usd for legacy reporting.
CREATE OR REPLACE FUNCTION public.usage_customer_billable_amount(
metadata jsonb, base_cost numeric, legacy_cost numeric
) RETURNS numeric LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE AS $$
) RETURNS numeric LANGUAGE plpgsql IMMUTABLE PARALLEL UNSAFE AS $$
DECLARE factor jsonb; multiplier numeric; amount numeric;
factor_name text; factor_value jsonb; factor_number double precision;
expected_multiplier double precision := 1.0; factor_count integer := 0;
@@ -28,6 +28,8 @@ use crate::lifecycle::bootstrap::postgres::{
};
mod customer_billing_upgrade;
mod dashboard_chart_billing;
mod dashboard_chart_history;
mod dashboard_user_anonymization;
mod legacy_overview_upgrade;
mod migration_deadlines;
@@ -1600,6 +1602,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
20261001000000,
20261004000000,
20261007000000,
20261008000000,
20261009000000,
]
);
@@ -0,0 +1,278 @@
use super::*;
use aether_data_contracts::repository::usage::{
UsageAnalyticsQuery, UsageAnalyticsView, UsageDashboardAnalyticsQuery,
};
use chrono::{Duration, Utc};
use serde_json::json;
#[tokio::test]
async fn dashboard_charts_match_summary_customer_charges_and_request_scope() {
let Some(server) = ManagedPostgresServer::try_start().await.unwrap() else {
return;
};
let pool = PgPool::connect(server.database_url()).await.unwrap();
prepare_and_apply_clean_postgres_database(&pool).await;
let repo = aether_data_postgres::SqlxUsageReadRepository::new(pool.clone());
let summary_query = UsageDashboardAnalyticsQuery {
timezone: "Asia/Shanghai".into(),
};
let start = summary_query.today_start(Utc::now()).unwrap();
query("UPDATE dashboard_stats_state SET stats_since=$1")
.bind(start)
.execute(&pool)
.await
.unwrap();
let composite = json!({"billing_multiplier_snapshot":{"version":1,"factors":{"routing_group":2,"user_group":0.25},"multiplier":0.5}});
for (id, provider, status, billing, base, actual, metadata) in [
(
"composite",
"alpha",
"completed",
"settled",
10.0,
Some(3.0),
composite,
),
(
"legacy",
"alpha",
"completed",
"settled",
2.0,
Some(1.25),
json!({}),
),
(
"free",
"alpha",
"completed",
"settled",
50.0,
Some(40.0),
json!({"routing_group_billing_multiplier":0}),
),
(
"invalid",
"alpha",
"completed",
"settled",
99.0,
Some(90.0),
json!({"billing_multiplier_snapshot":null}),
),
(
"unpriced",
"beta",
"completed",
"settled",
80.0,
Some(70.0),
json!({"usage_pricing_available":false}),
),
(
"failed",
"unknown",
"failed",
"settled",
0.0,
Some(0.0),
json!({}),
),
(
"pending",
"pending",
"pending",
"pending",
0.0,
None,
json!({}),
),
(
"streaming",
"beta",
"streaming",
"pending",
0.0,
None,
json!({}),
),
(
"session",
"alpha",
"completed",
"settled",
500.0,
Some(500.0),
json!({}),
),
] {
query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,total_cost_usd,actual_total_cost_usd,total_tokens,created_at,request_metadata,response_time_ms) VALUES($1,$1,'model',$2,$3,$4,$5,$6,10,$7,$8,1000)")
.bind(id).bind(provider).bind(status).bind(billing).bind(base).bind(actual)
.bind(start + Duration::seconds(1)).bind(metadata).execute(&pool).await.unwrap();
}
// The captured settlement cost, rather than the mutable audit float, is rated.
query("INSERT INTO usage_settlement_snapshots(request_id,billing_status,billing_total_cost_usd,billing_actual_total_cost_usd) VALUES('composite','settled',12,4)")
.execute(&pool).await.unwrap();
query(
"UPDATE usage_attribution_snapshots SET record_kind='session' WHERE request_id='session'",
)
.execute(&pool)
.await
.unwrap();
// Exclude both the previous local day and the next day's boundary.
for (id, at) in [
("before", start - Duration::seconds(1)),
("after", start + Duration::days(1)),
] {
query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,total_cost_usd,actual_total_cost_usd,created_at) VALUES($1,$1,'outside','outside','completed','settled',999,999,$2)")
.bind(id).bind(at).execute(&pool).await.unwrap();
}
let summary = repo.query_dashboard_summary(&summary_query).await.unwrap();
let charts = repo
.query_usage_analytics(&UsageAnalyticsQuery {
from_unix_ms: start.timestamp_millis() as u64,
to_unix_ms: (start + Duration::days(1)).timestamp_millis() as u64,
timezone: summary_query.timezone.clone(),
view: UsageAnalyticsView::DashboardCharts,
limit: 10_000,
..Default::default()
})
.await
.unwrap();
assert_eq!(summary.today.request_count, 8);
assert_eq!(summary.today.billable_amount.as_deref(), Some("7.25000000"));
assert_eq!(charts.summary.request_count, summary.today.request_count);
assert_eq!(
charts.summary.billable_amount,
summary.today.billable_amount
);
assert_eq!(
charts.summary.pricing_available_count,
summary.today.pricing_available_count
);
assert_eq!(charts.summary.in_flight_request_count, 2);
assert_eq!(charts.rows.len(), 1);
assert_eq!(charts.rows[0].metrics.request_count, 8);
assert_eq!(
charts.rows[0].metrics.billable_amount,
summary.today.billable_amount
);
assert_eq!(charts.rows[0].metrics.unique_providers, Some(2));
assert_eq!(
charts.rows[0].bucket_start.as_deref(),
Some(
start
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
.as_str()
)
);
for rows in [&charts.model_rows, &charts.provider_rows] {
assert_eq!(rows.iter().map(|r| r.metrics.request_count).sum::<u64>(), 8);
let charges: f64 = rows
.iter()
.filter_map(|r| r.metrics.billable_amount.as_ref())
.map(|v| v.parse::<f64>().unwrap())
.sum();
assert_eq!(charges, 7.25);
}
assert_eq!(
charts.provider_rows.len(),
4,
"legacy provider names must not collapse into one null-ID group"
);
// Retained older requests can have no contribution ledger entry. Mixing
// fallback rows with current cached rows must keep unknown pricing distinct
// from free usage and preserve the immutable composite settlement amount.
query("DELETE FROM dashboard_request_contributions WHERE request_id IN ('composite','free','invalid')")
.execute(&pool).await.unwrap();
let mixed = repo
.query_usage_analytics(&UsageAnalyticsQuery {
from_unix_ms: start.timestamp_millis() as u64,
to_unix_ms: (start + Duration::days(1)).timestamp_millis() as u64,
timezone: summary_query.timezone.clone(),
view: UsageAnalyticsView::DashboardCharts,
limit: 10_000,
..Default::default()
})
.await
.unwrap();
assert_eq!(
mixed.summary.billable_amount,
charts.summary.billable_amount
);
assert_eq!(mixed.summary.total_tokens, charts.summary.total_tokens);
assert_eq!(mixed.summary.request_count, charts.summary.request_count);
assert_eq!(
mixed.summary.pricing_available_count,
charts.summary.pricing_available_count
);
assert_eq!(mixed.rows.len(), charts.rows.len());
for (mixed, cached) in mixed.rows.iter().zip(&charts.rows) {
assert_eq!(mixed.bucket_start, cached.bucket_start);
assert_eq!(
mixed.metrics.billable_amount,
cached.metrics.billable_amount
);
assert_eq!(mixed.metrics.total_tokens, cached.metrics.total_tokens);
assert_eq!(
mixed.metrics.pricing_available_count,
cached.metrics.pricing_available_count
);
}
let empty = repo
.query_usage_analytics(&UsageAnalyticsQuery {
from_unix_ms: (start - Duration::days(2)).timestamp_millis() as u64,
to_unix_ms: (start - Duration::days(1)).timestamp_millis() as u64,
timezone: summary_query.timezone,
view: UsageAnalyticsView::DashboardCharts,
limit: 10_000,
..Default::default()
})
.await
.unwrap();
assert_eq!(empty.summary.request_count, 0);
assert_eq!(empty.summary.billable_amount.as_deref(), Some("0.00000000"));
assert_eq!(empty.rows[0].metrics.unique_providers, Some(0));
pool.close().await;
}
#[tokio::test]
async fn customer_billing_parallel_fix_preserves_history_without_backfill() {
let Some(server) = ManagedPostgresServer::try_start().await.unwrap() else {
return;
};
let mut connection = PgConnection::connect(server.database_url()).await.unwrap();
connection.ensure_migrations_table().await.unwrap();
for migration in POSTGRES_MIGRATOR
.iter()
.filter(|m| m.version < 20261008000000)
{
connection.apply(migration).await.unwrap();
}
let pool = PgPool::connect(server.database_url()).await.unwrap();
query("INSERT INTO stats_daily(id,date,total_requests,total_cost,actual_total_cost,is_complete) VALUES('untouched','2020-01-01',1,10,3,true)")
.execute(&pool).await.unwrap();
query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,total_cost_usd,actual_total_cost_usd,created_at,request_metadata) VALUES('untouched','untouched','m','p','completed','settled',10,3,'2020-01-01','{\"routing_group_billing_multiplier\":2}')")
.execute(&pool).await.unwrap();
let before: (serde_json::Value,serde_json::Value) = sqlx::query_as("SELECT (SELECT to_jsonb(d) FROM stats_daily d WHERE id='untouched'), (SELECT to_jsonb(u) FROM usage u WHERE id='untouched')").fetch_one(&pool).await.unwrap();
connection
.apply(
POSTGRES_MIGRATOR
.iter()
.find(|m| m.version == 20261008000000)
.unwrap(),
)
.await
.unwrap();
let after = sqlx::query_as("SELECT (SELECT to_jsonb(d) FROM stats_daily d WHERE id='untouched'), (SELECT to_jsonb(u) FROM usage u WHERE id='untouched')").fetch_one(&pool).await.unwrap();
assert_eq!(before, after);
let parallel: String=query_scalar("SELECT proparallel::text FROM pg_proc WHERE oid='public.usage_customer_billable_amount(jsonb,numeric,numeric)'::regprocedure").fetch_one(&pool).await.unwrap();
assert_eq!(parallel, "u");
// Encourage a parallel scan; the exception-handling function must keep it serial.
let mut tx = pool.begin().await.unwrap();
sqlx::raw_sql("CREATE TABLE billing_parallel_probe AS SELECT i::numeric AS cost FROM generate_series(1,10000) i; ALTER TABLE billing_parallel_probe SET (parallel_workers=2); ANALYZE billing_parallel_probe; SET LOCAL min_parallel_table_scan_size=0; SET LOCAL parallel_setup_cost=0; SET LOCAL parallel_tuple_cost=0;").execute(&mut *tx).await.unwrap();
let charge:String=query_scalar("SELECT sum(public.usage_customer_billable_amount('{\"routing_group_billing_multiplier\":2}'::jsonb,cost,1))::text FROM billing_parallel_probe").fetch_one(&mut *tx).await.unwrap();
assert_eq!(charge, "100010000.00000000");
tx.rollback().await.unwrap();
pool.close().await;
}
@@ -0,0 +1,144 @@
use super::*;
use aether_data_contracts::repository::usage::{UsageAnalyticsQuery, UsageAnalyticsView};
use chrono::{DateTime, Duration, Utc};
use serde_json::json;
#[tokio::test]
async fn dashboard_chart_history_coverage_matches_legacy_request_scope_without_raw_rescans() {
let Some(server) = ManagedPostgresServer::try_start().await.unwrap() else {
return;
};
let pool = PgPool::connect(server.database_url()).await.unwrap();
prepare_and_apply_clean_postgres_database(&pool).await;
let repo = aether_data_postgres::SqlxUsageReadRepository::new(pool.clone());
let start = "2020-01-01T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
query("INSERT INTO stats_summary(id,cutoff_date) VALUES('history',$1)")
.bind(start + Duration::days(4))
.execute(&pool)
.await
.unwrap();
for (day, count, complete) in [
(0, 2, true),
(1, 2, true),
(2, 1, true),
(3, 1, false),
(4, 1, true),
] {
query("INSERT INTO stats_daily(id,date,total_requests,total_cost,actual_total_cost,is_complete) VALUES($1,$2,$3,9999,7777,$4)")
.bind(format!("day-{day}"))
.bind(start + Duration::days(day))
.bind(count)
.bind(complete)
.execute(&pool).await.unwrap();
}
for (id, day, status, provider, session) in [
("retained-request", 0, "completed", "provider", false),
("retained-session", 0, "completed", "provider", true),
("partial-request", 1, "completed", "provider", false),
("partial-pending", 1, "pending", "provider", false),
("partial-unknown", 1, "failed", "unknown", false),
] {
query("INSERT INTO usage(id,request_id,model,provider_name,status,billing_status,total_cost_usd,actual_total_cost_usd,created_at,request_metadata) VALUES($1,$1,'model',$2,$3,'settled',2,1,$4,$5)")
.bind(id).bind(provider).bind(status)
.bind(start + Duration::days(day) + Duration::seconds(1))
.bind(json!({"routing_group_billing_multiplier":2,"analytics_attribution":{"record_kind":if session { "session" } else { "request" }}}))
.execute(&pool).await.unwrap();
}
query("UPDATE usage SET total_tokens=999,input_tokens=90,output_tokens=20,cache_creation_input_tokens=11,cache_read_input_tokens=13 WHERE id='retained-request'")
.execute(&pool).await.unwrap();
query("INSERT INTO usage_settlement_snapshots(request_id,billing_status,billing_effective_input_tokens,billing_output_tokens,billing_cache_creation_tokens,billing_cache_read_tokens) VALUES('retained-request','settled',7,3,5,2)")
.execute(&pool).await.unwrap();
let day_query = |day| UsageAnalyticsQuery {
from_unix_ms: (start + Duration::days(day)).timestamp_millis() as u64,
to_unix_ms: (start + Duration::days(day + 1)).timestamp_millis() as u64,
timezone: "UTC".into(),
view: UsageAnalyticsView::DashboardCharts,
limit: 10_000,
..Default::default()
};
// A retained session belongs to the old rollup, but never to chart totals.
let complete = repo.query_usage_analytics(&day_query(0)).await.unwrap();
assert_eq!(complete.unrecoverable_bucket_count, 0);
assert_eq!(complete.summary.request_count, 1);
assert_eq!(complete.summary.total_tokens, 17);
assert_eq!(
complete.summary.billable_amount.as_deref(),
Some("4.00000000")
);
let mut canonical_query = day_query(0);
canonical_query.model = Some("model".into());
let canonical = repo.query_usage_analytics(&canonical_query).await.unwrap();
assert_eq!(
complete.summary.total_tokens,
canonical.summary.total_tokens
);
assert_eq!(
complete.summary.billable_amount,
canonical.summary.billable_amount
);
// Pending and unknown-provider rows cannot disguise a missing legacy request.
let partial = repo.query_usage_analytics(&day_query(1)).await.unwrap();
assert_eq!(partial.unrecoverable_bucket_count, 24);
assert_eq!(partial.summary.request_count, 3);
assert_eq!(
partial.summary.billable_amount.as_deref(),
Some("12.00000000")
);
let missing = repo.query_usage_analytics(&day_query(2)).await.unwrap();
assert_eq!(missing.unrecoverable_bucket_count, 24);
assert_eq!(missing.summary.request_count, 0);
assert_eq!(missing.summary.billable_amount, None);
assert_eq!(missing.rows[0].metrics.billable_amount, None);
// Incomplete rollups, unpublished days and filtered views cannot establish
// that request details are missing from global legacy daily totals.
for day in [3, 4] {
let result = repo.query_usage_analytics(&day_query(day)).await.unwrap();
assert_eq!(result.unrecoverable_bucket_count, 0);
assert_eq!(
result.summary.billable_amount.as_deref(),
Some("0.00000000")
);
}
let mut filtered_query = day_query(2);
filtered_query.model = Some("model".into());
let filtered = repo.query_usage_analytics(&filtered_query).await.unwrap();
assert_eq!(filtered.unrecoverable_bucket_count, 0);
let mut partial_day_query = day_query(2);
partial_day_query.from_unix_ms += 60 * 60 * 1000;
let partial_day = repo
.query_usage_analytics(&partial_day_query)
.await
.unwrap();
assert_eq!(partial_day.unrecoverable_bucket_count, 23);
assert_eq!(partial_day.summary.billable_amount, None);
// A local day crosses two UTC archive days. Read both complete UTC days for
// coverage, while chart totals remain bounded to the original local range.
let local_day_query = UsageAnalyticsQuery {
from_unix_ms: (start + Duration::days(1) + Duration::hours(16)).timestamp_millis() as u64,
to_unix_ms: (start + Duration::days(2) + Duration::hours(16)).timestamp_millis() as u64,
timezone: "Asia/Shanghai".into(),
..day_query(2)
};
let local_day = repo.query_usage_analytics(&local_day_query).await.unwrap();
assert_eq!(local_day.unrecoverable_bucket_count, 24);
assert_eq!(local_day.summary.request_count, 0);
assert_eq!(local_day.rows.len(), 1);
assert_eq!(local_day.rows[0].metrics.billable_amount, None);
let mut retained_partial = day_query(0);
retained_partial.from_unix_ms += 60 * 60 * 1000;
let retained_partial = repo.query_usage_analytics(&retained_partial).await.unwrap();
assert_eq!(retained_partial.unrecoverable_bucket_count, 0);
assert_eq!(retained_partial.summary.request_count, 0);
// Existing lost-hour evidence and the inferred day's coverage are one set.
query("INSERT INTO stats_overview_dirty_events(transaction_id,projection_version,granularity,bucket_start,unrecoverable) VALUES(txid_current(),'overview-v2','hour',$1,true)")
.bind(start + Duration::days(2) + Duration::hours(3))
.execute(&pool).await.unwrap();
let deduplicated = repo.query_usage_analytics(&day_query(2)).await.unwrap();
assert_eq!(deduplicated.unrecoverable_bucket_count, 24);
pool.close().await;
}
@@ -116,6 +116,7 @@ WHERE version=20260919000000;
20261001000000,
20261004000000,
20261007000000,
20261008000000,
20261009000000,
]
);
@@ -435,6 +435,20 @@ impl InMemoryUsageReadRepository {
let metrics = |rows: &[&StoredRequestUsageAudit], slow| {
let mut result = metrics(rows, slow, &keys);
apply_allocations(&mut result, rows, &allocations);
if query.view == UsageAnalyticsView::DashboardCharts {
result.pricing_available_count = rows
.iter()
.filter(|row| {
row.billing_status == "settled"
&& available(row, USAGE_PRICING_AVAILABLE_METADATA_KEY)
&& row.billing_cost().is_some()
})
.count() as u64;
if rows.is_empty() {
result.billable_amount = Some("0.00000000".into());
result.rated_amount = Some("0.00000000".into());
}
}
result
};
let mut summary = metrics(&filtered, query.slow_threshold_ms.unwrap_or(5000));
@@ -682,17 +696,39 @@ impl InMemoryUsageReadRepository {
&& query.group_by == UsageAnalyticsGroupBy::Provider;
let mut grouped = groups
.into_iter()
.map(|(id, rows)| UsageAnalyticsRow {
label: if provider_breakdown {
provider_display_label(&rows, id.as_deref())
} else {
id.clone()
},
bucket_start: (query.view != UsageAnalyticsView::Breakdown)
.then(|| id.clone())
.flatten(),
id,
metrics: metrics(&rows, query.slow_threshold_ms.unwrap_or(5000)),
.map(|(id, rows)| {
let mut metrics = metrics(&rows, query.slow_threshold_ms.unwrap_or(5000));
if query.view == UsageAnalyticsView::DashboardCharts {
metrics.unique_providers = Some(
rows.iter()
.filter_map(|row| {
row.provider_id
.as_deref()
.filter(|id| !id.is_empty())
.or_else(|| {
(!matches!(
row.provider_name.as_str(),
"" | "unknown" | "pending"
))
.then_some(row.provider_name.as_str())
})
})
.collect::<BTreeSet<_>>()
.len() as u64,
);
}
UsageAnalyticsRow {
label: if provider_breakdown {
provider_display_label(&rows, id.as_deref())
} else {
id.clone()
},
bucket_start: (query.view != UsageAnalyticsView::Breakdown)
.then(|| id.clone())
.flatten(),
id,
metrics,
}
})
.collect::<Vec<_>>();
if query.view == UsageAnalyticsView::Breakdown {
@@ -754,7 +790,14 @@ impl InMemoryUsageReadRepository {
.today_start(at)?
};
providers
.entry(row.provider_id.clone())
.entry(
row.provider_id
.clone()
.filter(|id| !id.is_empty())
.or_else(|| {
(!row.provider_name.is_empty()).then(|| row.provider_name.clone())
}),
)
.or_default()
.push(row);
models