feat: revamp analytics dashboards and harden database migrations

Add dashboard and overview analytics, health monitoring, provider expense tracking, and announcement updates across the gateway and frontend.

Keep schema migrations free of historical backfills while preserving automatic backfill execution. Bound migration deadlines, run schema preparation before Compose replacement, and anonymize deleted dashboard users.

Include the current documentation cleanup and regression coverage.
This commit is contained in:
elky
2026-10-01 11:48:17 +08:00
parent 60b89cc840
commit 066ea87d72
327 changed files with 31728 additions and 20645 deletions
@@ -97,6 +97,39 @@ pub struct StoredAnnouncementPage {
pub total: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct UserAnnouncementListQuery {
pub unread_only: bool,
pub offset: usize,
pub limit: usize,
pub now_unix_secs: u64,
}
impl UserAnnouncementListQuery {
pub fn validate(&self) -> Result<(), crate::DataLayerError> {
if !(1..=100).contains(&self.limit) || i64::try_from(self.offset).is_err() {
return Err(crate::DataLayerError::InvalidInput(
"announcement limit must be between 1 and 100 and offset must fit in i64"
.to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct StoredUserAnnouncement {
pub announcement: StoredAnnouncement,
pub is_read: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct StoredUserAnnouncementPage {
pub items: Vec<StoredUserAnnouncement>,
pub total: u64,
pub unread_count: u64,
}
fn parse_timestamp(value: i64, field: &str) -> Result<u64, crate::DataLayerError> {
u64::try_from(value).map_err(|_| {
crate::DataLayerError::UnexpectedValue(format!("{field} is negative: {value}"))
@@ -115,6 +148,13 @@ pub trait AnnouncementReadRepository: Send + Sync {
query: &AnnouncementListQuery,
) -> Result<StoredAnnouncementPage, crate::DataLayerError>;
/// Counts and page share one snapshot; unread_count covers all currently active announcements.
async fn list_user_announcements(
&self,
user_id: &str,
query: &UserAnnouncementListQuery,
) -> Result<StoredUserAnnouncementPage, crate::DataLayerError>;
async fn count_unread_active_announcements(
&self,
user_id: &str,
@@ -1,3 +1,5 @@
mod provider_expenses;
pub use provider_expenses::*;
mod replacement;
mod types;
mod usage_policy;
@@ -0,0 +1,325 @@
//! An administrator-maintained purchasing ledger. Entries are not inferred from request prices.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
const SCALE: u128 = 100_000_000;
pub fn provider_expense_amount_units(value: &str) -> Option<u128> {
let (whole, fraction) = value.split_once('.').unwrap_or((value, ""));
if whole.is_empty()
|| whole.len() > 12
|| !whole.bytes().all(|c| c.is_ascii_digit())
|| fraction.len() > 8
|| !fraction.bytes().all(|c| c.is_ascii_digit())
{
return None;
}
let units = whole
.parse::<u128>()
.ok()?
.checked_mul(SCALE)?
.checked_add(if fraction.is_empty() {
0
} else {
fraction.parse::<u128>().ok()? * 10_u128.pow(8 - fraction.len() as u32)
})?;
(units > 0).then_some(units)
}
pub fn format_provider_expense_amount(units: u128) -> String {
format!("{}.{:08}", units / SCALE, units % SCALE)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderExpenseInput {
pub client_request_id: String,
pub provider_id: String,
pub provider_name: String,
pub kind: String,
pub amount: String,
pub currency: String,
pub paid_at_unix_ms: u64,
pub period_start_unix_ms: Option<u64>,
pub period_end_unix_ms: Option<u64>,
pub note: Option<String>,
pub external_reference: Option<String>,
pub created_by: Option<String>,
}
impl ProviderExpenseInput {
/// Provider display names are snapshots, so renames do not break a retry.
pub fn same_request_as(&self, other: &Self) -> bool {
self.client_request_id == other.client_request_id
&& self.provider_id == other.provider_id
&& self.kind == other.kind
&& provider_expense_amount_units(&self.amount)
== provider_expense_amount_units(&other.amount)
&& self.currency == other.currency
&& self.paid_at_unix_ms == other.paid_at_unix_ms
&& self.period_start_unix_ms == other.period_start_unix_ms
&& self.period_end_unix_ms == other.period_end_unix_ms
&& self.note == other.note
&& self.external_reference == other.external_reference
&& self.created_by == other.created_by
}
pub fn validate(&self) -> Result<(), String> {
if uuid::Uuid::parse_str(&self.client_request_id).is_err() {
return Err("client_request_id must be a UUID".into());
}
if self.provider_id.is_empty()
|| self.provider_id.len() > 512
|| self.provider_name.is_empty()
|| self.provider_name.len() > 512
{
return Err("invalid provider identity".into());
}
if !matches!(self.kind.as_str(), "recharge" | "subscription" | "other") {
return Err("kind must be recharge, subscription or other".into());
}
if provider_expense_amount_units(&self.amount).is_none() {
return Err(
"amount must be positive with at most 12 integer and 8 decimal digits".into(),
);
}
if self.currency.len() != 3 || !self.currency.bytes().all(|c| c.is_ascii_uppercase()) {
return Err("currency must be a 3-letter uppercase code".into());
}
if self.paid_at_unix_ms > 253_402_300_799_000
|| self
.period_start_unix_ms
.is_some_and(|v| v > 253_402_300_799_000)
|| self
.period_end_unix_ms
.is_some_and(|v| v > 253_402_300_799_000)
{
return Err("invalid timestamp".into());
}
match (self.period_start_unix_ms, self.period_end_unix_ms) {
(None, None) => {}
(Some(start), Some(end)) if start < end => {}
_ => {
return Err(
"coverage period must contain both start and end, with start before end".into(),
)
}
}
if self.note.as_ref().is_some_and(|v| {
v.len() > 2000 || v.chars().any(|c| c.is_control() && c != '\n' && c != '\t')
}) || self
.external_reference
.as_ref()
.is_some_and(|v| v.len() > 256 || v.chars().any(char::is_control))
{
return Err("invalid note or external_reference".into());
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderExpenseRecord {
pub id: String,
#[serde(flatten)]
pub entry: ProviderExpenseInput,
pub created_at_unix_ms: u64,
pub voided_at_unix_ms: Option<u64>,
pub voided_by: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ProviderExpenseQuery {
pub from_unix_ms: u64,
pub to_unix_ms: u64,
pub limit: u32,
pub offset: u64,
}
impl ProviderExpenseQuery {
pub fn validate(&self) -> Result<(), crate::DataLayerError> {
if self.from_unix_ms >= self.to_unix_ms
|| self.to_unix_ms > 253_402_300_799_000
|| self.to_unix_ms - self.from_unix_ms > 366 * 86_400_000
|| self.limit == 0
|| self.limit > 10_001
|| self.offset > i64::MAX as u64
{
return Err(crate::DataLayerError::InvalidInput(
"invalid provider expense range or pagination".into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProviderExpenseTotals {
pub currency: String,
pub amount: String,
pub recharge_amount: String,
pub subscription_amount: String,
pub other_amount: String,
pub entry_count: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderExpenseProviderTotal {
pub provider_id: String,
pub provider_name: String,
pub currency: String,
pub amount: String,
pub entry_count: u64,
}
#[derive(Debug, Clone, Default)]
pub struct ProviderExpensePage {
pub items: Vec<ProviderExpenseRecord>,
pub total: u64,
pub totals: Vec<ProviderExpenseTotals>,
pub providers: Vec<ProviderExpenseProviderTotal>,
}
pub fn provider_expense_memory_page(
entries: impl Iterator<Item = ProviderExpenseRecord>,
query: &ProviderExpenseQuery,
) -> Result<ProviderExpensePage, crate::DataLayerError> {
query.validate()?;
let mut entries = entries
.filter(|r| {
r.voided_at_unix_ms.is_none()
&& r.entry.paid_at_unix_ms >= query.from_unix_ms
&& r.entry.paid_at_unix_ms < query.to_unix_ms
})
.collect::<Vec<_>>();
entries.sort_by(|a, b| {
b.entry
.paid_at_unix_ms
.cmp(&a.entry.paid_at_unix_ms)
.then_with(|| b.id.cmp(&a.id))
});
let mut currencies = BTreeMap::<String, ([u128; 3], u64)>::new();
let mut providers = BTreeMap::<(String, String), (String, u128, u64)>::new();
for record in &entries {
let row = &record.entry;
let units = provider_expense_amount_units(&row.amount).ok_or_else(|| {
crate::DataLayerError::UnexpectedValue("invalid recorded expense amount".into())
})?;
let (amounts, count) = currencies.entry(row.currency.clone()).or_default();
amounts[match row.kind.as_str() {
"recharge" => 0,
"subscription" => 1,
_ => 2,
}] += units;
*count += 1;
let (name, amount, count) = providers
.entry((row.provider_id.clone(), row.currency.clone()))
.or_insert_with(|| (row.provider_name.clone(), 0, 0));
let _ = name;
*amount += units;
*count += 1;
}
Ok(ProviderExpensePage {
total: entries.len() as u64,
items: entries
.into_iter()
.skip(query.offset as usize)
.take(query.limit as usize)
.collect(),
totals: currencies
.into_iter()
.map(|(currency, (amounts, entry_count))| ProviderExpenseTotals {
currency,
amount: format_provider_expense_amount(amounts.iter().sum()),
recharge_amount: format_provider_expense_amount(amounts[0]),
subscription_amount: format_provider_expense_amount(amounts[1]),
other_amount: format_provider_expense_amount(amounts[2]),
entry_count,
})
.collect(),
providers: providers
.into_iter()
.map(
|((provider_id, currency), (provider_name, amount, entry_count))| {
ProviderExpenseProviderTotal {
provider_id,
provider_name,
currency,
amount: format_provider_expense_amount(amount),
entry_count,
}
},
)
.collect(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_expense_amounts_are_exact_and_reject_ambiguous_inputs() {
assert_eq!(
format_provider_expense_amount(provider_expense_amount_units("0.12345678").unwrap()),
"0.12345678"
);
for bad in [
"0",
"-1",
"+1",
"1e2",
"NaN",
"1.000000001",
"1000000000000",
" 1",
".1",
] {
assert!(provider_expense_amount_units(bad).is_none(), "{bad}");
}
}
#[test]
fn provider_expense_report_has_currency_separation_void_exclusion_and_full_page_totals() {
let entry =
|id: &str, currency: &str, amount: &str, kind: &str, paid: u64, voided: bool| {
ProviderExpenseRecord {
id: id.into(),
entry: ProviderExpenseInput {
client_request_id: uuid::Uuid::new_v4().to_string(),
provider_id: "p".into(),
provider_name: "Supplier".into(),
kind: kind.into(),
amount: amount.into(),
currency: currency.into(),
paid_at_unix_ms: paid,
period_start_unix_ms: None,
period_end_unix_ms: None,
note: None,
external_reference: None,
created_by: None,
},
created_at_unix_ms: paid,
voided_at_unix_ms: voided.then_some(paid + 1),
voided_by: None,
}
};
let rows = vec![
entry("1", "USD", "0.1", "recharge", 10, false),
entry("2", "USD", "0.2", "subscription", 20, false),
entry("3", "CNY", "5", "other", 20, false),
entry("4", "USD", "99", "recharge", 20, true),
entry("5", "USD", "99", "recharge", 30, false),
];
let page = provider_expense_memory_page(
rows.into_iter(),
&ProviderExpenseQuery {
from_unix_ms: 10,
to_unix_ms: 30,
limit: 1,
offset: 1,
},
)
.unwrap();
assert_eq!(page.total, 3);
assert_eq!(page.items.len(), 1);
assert_eq!(page.totals[0].currency, "CNY");
assert_eq!(page.totals[0].amount, "5.00000000");
assert_eq!(page.totals[1].amount, "0.30000000");
assert_eq!(page.totals[1].recharge_amount, "0.10000000");
assert_eq!(page.totals[1].subscription_amount, "0.20000000");
}
}
@@ -576,6 +576,31 @@ pub trait BillingReadRepository: Send + Sync {
Ok(AdminBillingMutationOutcome::Unavailable)
}
async fn list_provider_expenses(
&self,
query: &super::ProviderExpenseQuery,
) -> Result<Option<super::ProviderExpensePage>, crate::DataLayerError> {
let _ = query;
Ok(None)
}
async fn create_provider_expense(
&self,
input: &super::ProviderExpenseInput,
) -> Result<AdminBillingMutationOutcome<super::ProviderExpenseRecord>, crate::DataLayerError>
{
let _ = input;
Ok(AdminBillingMutationOutcome::Unavailable)
}
async fn void_provider_expense(
&self,
id: &str,
operator: Option<&str>,
) -> Result<AdminBillingMutationOutcome<super::ProviderExpenseRecord>, crate::DataLayerError>
{
let _ = (id, operator);
Ok(AdminBillingMutationOutcome::Unavailable)
}
async fn list_billing_plans(
&self,
include_disabled: bool,
@@ -634,6 +659,18 @@ pub trait BillingReadRepository: Send + Sync {
Ok(None)
}
async fn list_user_plan_entitlements_with_history(
&self,
user_id: &str,
include_inactive: bool,
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, crate::DataLayerError> {
if include_inactive {
Ok(None)
} else {
self.list_user_plan_entitlements(user_id).await
}
}
async fn revoke_user_plan_entitlement(
&self,
user_id: &str,
@@ -0,0 +1,454 @@
use serde::{Deserialize, Serialize};
pub const USAGE_ANALYTICS_VERSION: &str = "overview-v2";
pub const USAGE_ANALYTICS_MAX_RANGE_MS: u64 = 366 * 24 * 60 * 60 * 1000;
pub const USAGE_DASHBOARD_CHART_ROW_LIMIT: usize = 10_000;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageDashboardAnalyticsQuery {
pub timezone: String,
}
impl UsageDashboardAnalyticsQuery {
pub fn validate(&self) -> Result<(), crate::DataLayerError> {
self.timezone
.parse::<chrono_tz::Tz>()
.map(|_| ())
.map_err(|_| crate::DataLayerError::InvalidInput("invalid analytics timezone".into()))
}
pub fn today_start(
&self,
now: chrono::DateTime<chrono::Utc>,
) -> Result<chrono::DateTime<chrono::Utc>, crate::DataLayerError> {
self.validate()?;
let timezone = self.timezone.parse::<chrono_tz::Tz>().expect("validated");
local_day_start(timezone, now.with_timezone(&timezone).date_naive()).ok_or_else(|| {
crate::DataLayerError::InvalidInput("reporting day boundary is unavailable".into())
})
}
}
fn local_day_start(
timezone: chrono_tz::Tz,
day: chrono::NaiveDate,
) -> Option<chrono::DateTime<chrono::Utc>> {
use chrono::TimeZone;
let midnight = day.and_hms_opt(0, 0, 0)?;
// IANA transitions can skip midnight or an entire local calendar day.
(0..1440)
.find_map(|minutes| {
timezone
.from_local_datetime(&(midnight + chrono::Duration::minutes(minutes)))
.earliest()
})
.map(|value| value.with_timezone(&chrono::Utc))
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct StoredUsageDashboardAnalytics {
pub today: StoredUsageAnalytics,
// Lifetime card totals and coverage only; historical diagnostics are not computed.
pub total: StoredUsageAnalytics,
pub today_from: String,
pub total_from: Option<String>,
pub to: String,
// False means known lost history; None means installation-wide retention is unproven.
pub history_complete: Option<bool>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UsageAnalyticsView {
#[default]
Summary,
Timeseries,
Breakdown,
Users,
Consumption,
Performance,
DashboardCharts,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UsageAnalyticsGroupBy {
#[default]
Model,
Provider,
ApiKey,
Attribution,
ApiFormat,
RequestType,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UsageAnalyticsGranularity {
Hour,
#[default]
Day,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UsageAnalyticsSort {
#[default]
Requests,
BillableAmount,
LastUsed,
Username,
Tokens,
ActiveDays,
StartedAt,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageAnalyticsQuery {
pub from_unix_ms: u64,
pub to_unix_ms: u64,
pub timezone: String,
pub view: UsageAnalyticsView,
pub group_by: UsageAnalyticsGroupBy,
pub granularity: UsageAnalyticsGranularity,
pub actor_user_id: Option<String>,
pub credential_owner_id: Option<String>,
pub attribution_kind: Option<String>,
pub api_key_id: Option<String>,
pub model: Option<String>,
pub provider_id: Option<String>,
pub api_format: Option<String>,
pub endpoint_kind: Option<String>,
pub request_type: Option<String>,
pub status: Option<String>,
pub is_stream: Option<bool>,
pub has_format_conversion: Option<bool>,
pub slow_threshold_ms: Option<u64>,
pub search: Option<String>,
pub user_is_active: Option<bool>,
pub has_usage: Option<bool>,
pub sort: UsageAnalyticsSort,
pub descending: bool,
pub limit: u32,
pub offset: u64,
#[serde(default)]
pub payment_limit: Option<u32>,
#[serde(default)]
pub payment_offset: Option<u64>,
}
impl UsageAnalyticsQuery {
pub fn validate(&self) -> Result<(), crate::DataLayerError> {
if self.from_unix_ms >= self.to_unix_ms
|| self.to_unix_ms - self.from_unix_ms > USAGE_ANALYTICS_MAX_RANGE_MS
|| self.to_unix_ms > 253_402_300_799_000
{
return Err(crate::DataLayerError::InvalidInput(
"analytics range must be nonempty and at most 366 days".into(),
));
}
if self.view == UsageAnalyticsView::DashboardCharts
&& self.granularity == UsageAnalyticsGranularity::Hour
&& self.to_unix_ms - self.from_unix_ms > 31 * 24 * 60 * 60 * 1000
{
return Err(crate::DataLayerError::InvalidInput(
"hourly dashboard charts are limited to 31 days".into(),
));
}
if self.timezone.parse::<chrono_tz::Tz>().is_err() {
return Err(crate::DataLayerError::InvalidInput(
"invalid analytics timezone".into(),
));
}
if self.limit == 0 || self.limit > 10_001 || self.offset > i64::MAX as u64 {
return Err(crate::DataLayerError::InvalidInput(
"invalid analytics pagination".into(),
));
}
if self
.payment_limit
.is_some_and(|value| value == 0 || value > 100)
|| self
.payment_offset
.is_some_and(|value| value > i64::MAX as u64)
|| (self.view != UsageAnalyticsView::Users
&& (self.payment_limit.is_some() || self.payment_offset.is_some()))
{
return Err(crate::DataLayerError::InvalidInput(
"invalid user payment pagination".into(),
));
}
if self
.attribution_kind
.as_deref()
.is_some_and(|kind| !matches!(kind, "employee" | "standalone" | "unknown"))
{
return Err(crate::DataLayerError::InvalidInput(
"invalid attribution kind".into(),
));
}
Ok(())
}
}
/// Raw domain metrics. Amounts are per-request normalized decimal sums, never floats.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct UsageAnalyticsMetrics {
pub request_count: u64,
pub successful_request_count: u64,
pub failed_request_count: u64,
pub cancelled_request_count: u64,
pub in_flight_request_count: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub total_tokens: u64,
pub cache_read_input_tokens: u64,
pub cache_creation_input_tokens: u64,
pub cache_pricing_available_count: u64,
pub cache_read_cost_amount: Option<String>,
pub cache_creation_cost_amount: Option<String>,
pub cache_estimated_full_cost_amount: Option<String>,
pub usage_active_users: u64,
pub enabled_users: u64,
pub usage_available_count: u64,
pub reported_usage_count: u64,
pub estimated_usage_count: u64,
pub mixed_usage_count: u64,
pub unknown_usage_count: u64,
pub pricing_available_count: u64,
pub settled_count: u64,
pub allocation_available_count: u64,
pub trusted_attribution_count: u64,
pub classified_failure_count: u64,
pub latency_sample_count: u64,
pub slow_request_count: u64,
pub latency_sum_ms: f64,
pub latency_p50_ms: Option<f64>,
pub latency_p95_ms: Option<f64>,
pub latency_p90_ms: Option<f64>,
pub latency_p99_ms: Option<f64>,
pub first_byte_sample_count: u64,
pub first_byte_sum_ms: f64,
pub first_byte_p90_ms: Option<f64>,
pub first_byte_p99_ms: Option<f64>,
pub output_tps_sample_count: u64,
pub output_tps_sum: f64,
pub rated_amount: Option<String>,
pub billable_amount: Option<String>,
pub quota_covered_amount: Option<String>,
pub wallet_consumed_amount: Option<String>,
pub wallet_debit_amount: Option<String>,
pub wallet_recharge_debit_amount: Option<String>,
pub wallet_gift_debit_amount: Option<String>,
pub wallet_overdraft_amount: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UsageAnalyticsRow {
pub id: Option<String>,
pub label: Option<String>,
pub bucket_start: Option<String>,
pub metrics: UsageAnalyticsMetrics,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UsageAnalyticsUser {
pub user_id: String,
pub username: String,
pub email: Option<String>,
pub is_active: bool,
pub last_used_at: Option<String>,
pub active_days: u64,
pub metrics: UsageAnalyticsMetrics,
#[serde(default)]
pub finance: Option<UsageAnalyticsUserFinance>,
}
/// Current balances and gross credited orders in the requested time range.
/// Amounts are USD decimals. Gift-code/admin-grant orders and plan purchases
/// remain separate from wallet recharges; refunds are not assigned to the
/// original credit period.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageAnalyticsUserFinance {
pub wallet_balance: Option<String>,
pub recharge_balance: Option<String>,
pub gift_balance: Option<String>,
pub recharge_amount: Option<String>,
pub recharge_count: u64,
pub plan_purchase_amount: Option<String>,
pub plan_purchase_count: u64,
pub gift_credit_amount: Option<String>,
pub gift_credit_count: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageAnalyticsUserPayment {
pub id: String,
pub order_no: String,
pub kind: String,
pub amount: String,
pub payment_method: String,
pub credited_at: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageAnalyticsUserPayments {
pub items: Vec<UsageAnalyticsUserPayment>,
pub total: u64,
pub limit: u32,
pub offset: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct UsageAnalyticsUserSummary {
pub user_count: u64,
pub active_user_count: u64,
pub metrics: UsageAnalyticsMetrics,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct UsageAnalyticsConsumption {
pub id: String,
pub request_id: String,
pub started_at: String,
pub user_id: Option<String>,
pub credential_owner_id: Option<String>,
pub model: String,
pub provider: Option<String>,
pub provider_id: Option<String>,
pub api_key_id: Option<String>,
pub status: String,
pub settlement_status: String,
pub attribution_kind: String,
pub attribution_source: String,
pub rated_amount: Option<String>,
pub billable_amount: Option<String>,
pub quota_covered_amount: Option<String>,
pub wallet_consumed_amount: Option<String>,
pub wallet_debit_amount: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageAnalyticsAllocation {
pub request_id: String,
pub quota_covered_amount: Option<String>,
pub wallet_consumed_amount: Option<String>,
pub wallet_debit_amount: Option<String>,
pub wallet_recharge_debit_amount: Option<String>,
pub wallet_gift_debit_amount: Option<String>,
pub wallet_overdraft_amount: Option<String>,
pub cache_read_cost_amount: Option<String>,
pub cache_creation_cost_amount: Option<String>,
pub cache_estimated_full_cost_amount: Option<String>,
pub complete: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct StoredUsageAnalytics {
pub summary: UsageAnalyticsMetrics,
pub rows: Vec<UsageAnalyticsRow>,
pub users: Vec<UsageAnalyticsUser>,
#[serde(default)]
pub user_summary: Option<UsageAnalyticsUserSummary>,
#[serde(default)]
pub user_finance_summary: Option<UsageAnalyticsUserFinance>,
#[serde(default)]
pub user_payments: Option<UsageAnalyticsUserPayments>,
pub consumption: Vec<UsageAnalyticsConsumption>,
pub provider_rows: Vec<UsageAnalyticsRow>,
pub provider_timeline_rows: Vec<UsageAnalyticsRow>,
pub model_rows: Vec<UsageAnalyticsRow>,
pub errors: Vec<UsageAnalyticsErrorCount>,
pub total: u64,
pub read_revision: String,
pub generated_at: String,
pub data_through: Option<String>,
pub unrecoverable_bucket_count: u64,
pub coverage: UsageAnalyticsProjectionCoverage,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageAnalyticsProjectionCoverage {
pub projection_from: Option<String>,
pub projection_through: Option<String>,
pub dirty_bucket_count: u64,
pub missing_bucket_count: u64,
pub read_enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageAnalyticsErrorCount {
pub reason: String,
pub count: u64,
}
pub fn fill_usage_analytics_timeseries(
query: &UsageAnalyticsQuery,
rows: &mut Vec<UsageAnalyticsRow>,
) {
use chrono::{Timelike, Utc};
let timezone = query
.timezone
.parse::<chrono_tz::Tz>()
.expect("validated timezone");
let from = chrono::DateTime::<Utc>::from_timestamp_millis(query.from_unix_ms as i64)
.expect("validated timestamp");
let mut bucket = match query.granularity {
UsageAnalyticsGranularity::Hour => from
.with_minute(0)
.and_then(|value| value.with_second(0))
.and_then(|value| value.with_nanosecond(0))
.expect("hour"),
UsageAnalyticsGranularity::Day => {
let day = from.with_timezone(&timezone).date_naive();
let Some(value) = local_day_start(timezone, day) else {
return;
};
value.with_timezone(&Utc)
}
};
let mut existing = std::mem::take(rows)
.into_iter()
.filter_map(|row| {
let start = row
.bucket_start
.as_ref()
.and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok())?
.timestamp_millis();
Some((start, row))
})
.collect::<std::collections::BTreeMap<_, _>>();
while bucket.timestamp_millis() < query.to_unix_ms as i64 {
let start = bucket.to_rfc3339();
rows.push(
existing
.remove(&bucket.timestamp_millis())
.unwrap_or_else(|| UsageAnalyticsRow {
id: Some(start.clone()),
label: Some(start.clone()),
bucket_start: Some(start),
metrics: UsageAnalyticsMetrics {
rated_amount: Some("0.00000000".into()),
billable_amount: Some("0.00000000".into()),
..Default::default()
},
}),
);
bucket = match query.granularity {
UsageAnalyticsGranularity::Hour => bucket + chrono::Duration::hours(1),
UsageAnalyticsGranularity::Day => {
let mut day = bucket.with_timezone(&timezone).date_naive();
loop {
let Some(next) = day.succ_opt() else {
return;
};
day = next;
if let Some(value) = local_day_start(timezone, day) {
break value;
}
}
}
};
}
}
@@ -0,0 +1,102 @@
use super::*;
use chrono::DateTime;
fn query(
from: &str,
to: &str,
timezone: &str,
granularity: UsageAnalyticsGranularity,
) -> UsageAnalyticsQuery {
UsageAnalyticsQuery {
from_unix_ms: DateTime::parse_from_rfc3339(from)
.unwrap()
.timestamp_millis() as u64,
to_unix_ms: DateTime::parse_from_rfc3339(to).unwrap().timestamp_millis() as u64,
timezone: timezone.into(),
granularity,
limit: 25,
..Default::default()
}
}
#[test]
fn local_days_follow_dst_and_empty_buckets_remain_visible() {
let query = query(
"2026-03-07T05:00:00Z",
"2026-03-10T04:00:00Z",
"America/New_York",
UsageAnalyticsGranularity::Day,
);
let mut rows = Vec::new();
fill_usage_analytics_timeseries(&query, &mut rows);
assert_eq!(rows.len(), 3);
assert_eq!(
rows[2].bucket_start.as_deref(),
Some("2026-03-09T04:00:00+00:00")
);
assert_eq!(
rows[0].metrics.billable_amount.as_deref(),
Some("0.00000000")
);
}
#[test]
fn repeated_dst_hours_are_distinct_and_range_is_half_open() {
let query = query(
"2026-11-01T04:00:00Z",
"2026-11-01T08:00:00Z",
"America/New_York",
UsageAnalyticsGranularity::Hour,
);
let mut rows = Vec::new();
fill_usage_analytics_timeseries(&query, &mut rows);
assert_eq!(rows.len(), 4);
assert_ne!(rows[1].bucket_start, rows[2].bucket_start);
}
#[test]
fn dashboard_today_uses_local_day_including_skipped_midnight() {
let query = UsageDashboardAnalyticsQuery {
timezone: "America/Sao_Paulo".into(),
};
let now = DateTime::parse_from_rfc3339("2018-11-04T12:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
assert_eq!(
query.today_start(now).unwrap().to_rfc3339(),
"2018-11-04T03:00:00+00:00"
);
let query = UsageDashboardAnalyticsQuery {
timezone: "Asia/Shanghai".into(),
};
assert_eq!(
query.today_start(now).unwrap().to_rfc3339(),
"2018-11-03T16:00:00+00:00"
);
assert!(UsageDashboardAnalyticsQuery {
timezone: "invalid".into()
}
.validate()
.is_err());
}
#[test]
fn daily_series_continues_after_skipped_midnight() {
let query = query(
"2026-09-05T04:00:00Z",
"2026-09-08T03:00:00Z",
"America/Santiago",
UsageAnalyticsGranularity::Day,
);
let mut rows = Vec::new();
fill_usage_analytics_timeseries(&query, &mut rows);
assert_eq!(rows.len(), 3);
assert_eq!(
rows[1].bucket_start.as_deref(),
Some("2026-09-06T04:00:00+00:00")
);
assert_eq!(
rows[2].bucket_start.as_deref(),
Some("2026-09-07T03:00:00+00:00")
);
}
@@ -0,0 +1,76 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageAttributionSnapshot {
pub request_id: String,
pub actor_user_id: Option<String>,
pub credential_owner_id: Option<String>,
pub attribution_kind: String,
pub attribution_source: String,
pub record_kind: String,
pub parent_request_id: Option<String>,
pub schema_version: u32,
pub attribution_revision: u64,
}
impl UsageAttributionSnapshot {
pub fn validate(&self) -> Result<(), crate::DataLayerError> {
if self.request_id.is_empty()
|| self.attribution_revision == 0
|| self.schema_version != 1
|| !matches!(
self.attribution_kind.as_str(),
"employee" | "standalone" | "unknown"
)
|| !matches!(
self.attribution_source.as_str(),
"user_account" | "standalone_key" | "unknown"
)
|| (self.attribution_kind == "employee") != self.actor_user_id.is_some()
|| (self.attribution_kind == "employee"
&& (self.actor_user_id != self.credential_owner_id
|| self.attribution_source != "user_account"))
|| (self.attribution_kind == "standalone"
&& (self.credential_owner_id.is_none()
|| self.attribution_source != "standalone_key"))
|| (self.attribution_kind == "unknown" && self.attribution_source != "unknown")
{
return Err(crate::DataLayerError::InvalidInput(
"invalid usage attribution snapshot".into(),
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::UsageAttributionSnapshot;
#[test]
fn account_attribution_requires_matching_owner_and_key_classification() {
let mut snapshot = UsageAttributionSnapshot {
request_id: "request".into(),
actor_user_id: Some("member".into()),
credential_owner_id: Some("member".into()),
attribution_kind: "employee".into(),
attribution_source: "user_account".into(),
record_kind: "request".into(),
parent_request_id: None,
schema_version: 1,
attribution_revision: 2,
};
assert!(snapshot.validate().is_ok());
snapshot.actor_user_id = Some("another-member".into());
assert!(snapshot.validate().is_err());
snapshot.actor_user_id = None;
snapshot.attribution_kind = "standalone".into();
snapshot.attribution_source = "standalone_key".into();
assert!(snapshot.validate().is_ok());
snapshot.credential_owner_id = None;
assert!(snapshot.validate().is_err());
snapshot.attribution_kind = "unknown".into();
snapshot.attribution_source = "unknown".into();
assert!(snapshot.validate().is_ok());
}
}
@@ -0,0 +1,126 @@
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
/// Additive dashboard facts collected after this installation enabled aggregation.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DashboardSummaryMetrics {
pub request_count: u64,
pub input_tokens: u64,
pub output_tokens: u64,
pub total_tokens: u64,
pub usage_available_count: u64,
pub pricing_available_count: u64,
pub billable_amount: Option<String>,
pub active_users: u64,
pub cache_read_tokens: u64,
pub cache_creation_tokens: u64,
pub cache_input_tokens: u64,
pub first_byte_sum_ms: f64,
pub first_byte_sample_count: u64,
pub response_sum_ms: f64,
pub response_sample_count: u64,
pub stream_requests: u64,
pub standard_requests: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DashboardUserCounts {
pub total: u64,
pub created_today: u64,
pub deleted_today: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DashboardActivityDay {
pub date: String,
pub requests: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct StoredDashboardSummary {
pub stats_since: String,
pub generated_at: String,
pub timezone: String,
pub today_from: String,
pub window_seconds: f64,
pub today: DashboardSummaryMetrics,
pub total: DashboardSummaryMetrics,
pub users: DashboardUserCounts,
pub active_days: u64,
#[serde(default)]
pub consecutive_active_days: u64,
pub activity_days: Vec<DashboardActivityDay>,
}
/// Current activity streak from distinct local dates ordered oldest to newest.
/// An unfinished today may be inactive, so a streak ending yesterday still counts.
pub fn dashboard_consecutive_active_days(
days: impl DoubleEndedIterator<Item = NaiveDate>,
today: NaiveDate,
) -> u64 {
let mut days = days.rev().filter(|date| *date <= today);
let Some(mut latest) = days.next() else {
return 0;
};
if latest != today && Some(latest) != today.pred_opt() {
return 0;
}
let mut consecutive = 1;
for date in days {
if Some(date) != latest.pred_opt() {
break;
}
consecutive += 1;
latest = date;
}
consecutive
}
#[cfg(test)]
mod tests {
use super::*;
fn date(value: &str) -> NaiveDate {
NaiveDate::parse_from_str(value, "%Y-%m-%d").unwrap()
}
#[test]
fn dashboard_activity_streak_handles_empty_stale_and_interrupted_days() {
let today = date("2026-09-19");
assert_eq!(dashboard_consecutive_active_days([].into_iter(), today), 0);
assert_eq!(
dashboard_consecutive_active_days([date("2026-09-17")].into_iter(), today),
0
);
assert_eq!(
dashboard_consecutive_active_days(
["2026-09-15", "2026-09-17", "2026-09-18", "2026-09-19"]
.map(date)
.into_iter(),
today,
),
3
);
}
#[test]
fn dashboard_activity_streak_can_end_yesterday_across_month_and_year() {
assert_eq!(
dashboard_consecutive_active_days(
["2025-12-30", "2025-12-31", "2026-01-01"]
.map(date)
.into_iter(),
date("2026-01-02"),
),
3
);
}
#[test]
fn dashboard_activity_streak_uses_full_history_and_ignores_future_dates() {
let today = date("2026-09-19");
let days = (-399..=1).map(|offset| today + chrono::Duration::days(offset));
assert_eq!(dashboard_consecutive_active_days(days, today), 400);
}
}
@@ -0,0 +1,62 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HealthObservationObjectKind {
ApiFormat,
Model,
Provider,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HealthObservationQuery {
pub from_unix_ms: u64,
pub to_unix_ms: u64,
pub object_kind: HealthObservationObjectKind,
/// None means all authorized administrative objects; Some(empty) means no objects.
pub object_values: Option<Vec<String>>,
pub segments: u32,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct HealthObservationMetrics {
pub request_count: u64,
pub succeeded_count: u64,
pub failed_count: u64,
pub in_progress_count: u64,
pub cancelled_count: u64,
pub service_succeeded_count: u64,
pub service_failed_count: u64,
pub excluded_count: u64,
pub unknown_failure_count: u64,
pub attempt_succeeded_count: u64,
pub attempt_failed_count: u64,
pub attempt_in_progress_count: u64,
pub attempt_cancelled_count: u64,
pub latency_sum_ms: f64,
pub latency_sample_count: u64,
pub last_request_at_unix_ms: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HealthObservationBucket {
pub from_unix_ms: u64,
pub to_unix_ms: u64,
pub metrics: HealthObservationMetrics,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HealthObservationObject {
pub object_value: String,
pub metrics: HealthObservationMetrics,
pub timeline: Vec<HealthObservationBucket>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct HealthObservationSummary {
pub overall: HealthObservationMetrics,
pub objects: Vec<HealthObservationObject>,
pub timeline: Vec<HealthObservationBucket>,
pub data_through_unix_ms: Option<u64>,
}
@@ -44,6 +44,40 @@ pub fn sanitize_usage_request_metadata_ref(value: Option<&Value>) -> Option<Valu
pub fn sanitize_usage_request_metadata_object(source: &Map<String, Value>) -> Option<Value> {
let mut target = Map::new();
if let Some(source) = source
.get("analytics_measurement")
.and_then(|value| value.get("source"))
.and_then(Value::as_str)
.filter(|source| matches!(*source, "reported" | "estimated" | "mixed" | "unknown"))
{
target.insert(
"analytics_measurement".into(),
serde_json::json!({"source":source}),
);
}
for (key, fields) in [
(
"analytics_attribution",
&["record_kind", "parent_request_id"][..],
),
("analytics_failure", &["origin", "stage", "reason"][..]),
] {
if let Some(object) = source.get(key).and_then(Value::as_object) {
let mut projected = Map::new();
for field in fields {
insert_token(object, &mut projected, field, 128);
}
if key == "analytics_attribution" {
if let Some(value) = object.get("is_standalone").and_then(Value::as_bool) {
projected.insert("is_standalone".into(), Value::Bool(value));
}
}
insert_bounded_u64(object, &mut projected, "schema_version", 1);
if !projected.is_empty() {
target.insert(key.into(), Value::Object(projected));
}
}
}
insert_token(source, &mut target, "trace_id", 128);
insert_ip_address(source, &mut target, "client_ip");
@@ -1224,6 +1258,27 @@ mod tests {
use super::{sanitize_usage_request_metadata, sanitize_usage_request_metadata_ref};
#[test]
fn account_attribution_preserves_key_flag_without_custom_identity_or_purpose() {
let metadata = sanitize_usage_request_metadata(Some(json!({
"analytics_attribution": {
"is_standalone": false,
"record_kind": "request",
"actor_user_id": "another-member",
"credential_kind": "personal",
"source": "trusted_identity"
}
})))
.unwrap();
assert_eq!(
metadata["analytics_attribution"],
json!({
"is_standalone": false,
"record_kind": "request"
})
);
}
#[test]
fn persistence_projection_drops_credentials_and_free_diagnostics() {
let metadata = sanitize_usage_request_metadata(Some(json!({
@@ -1,15 +1,25 @@
mod analytics;
#[cfg(test)]
mod analytics_tests;
mod attribution;
mod capture_memory;
mod compression;
mod dashboard_summary;
mod health;
mod metadata_policy;
mod policy;
mod types;
pub use analytics::*;
pub use attribution::*;
#[doc(hidden)]
pub use capture_memory::{
mark_usage_capture_memory_omitted, usage_json_heap_estimate, UsageCaptureMemoryBudget,
UsageCaptureRetention,
};
pub use compression::{read_decompressed_usage_json, MAX_DECOMPRESSED_USAGE_JSON_BYTES};
pub use dashboard_summary::*;
pub use health::*;
pub use metadata_policy::*;
pub use policy::*;
pub use types::{
@@ -995,6 +995,24 @@ pub struct StoredProviderApiKeyWindowUsageSummary {
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct UsageAuditListQuery {
#[serde(default)]
pub slow_threshold_ms: Option<u64>,
#[serde(default)]
pub endpoint_kind: Option<String>,
#[serde(default)]
pub request_type: Option<String>,
#[serde(default)]
pub has_format_conversion: Option<bool>,
#[serde(default)]
pub provider_id: Option<String>,
#[serde(default)]
pub api_key_id: Option<String>,
#[serde(default)]
pub request_id: Option<String>,
#[serde(default)]
pub attribution_kind: Option<String>,
#[serde(default)]
pub actor_user_id: Option<String>,
pub created_from_unix_secs: Option<u64>,
pub created_until_unix_secs: Option<u64>,
pub user_id: Option<String>,
@@ -1015,6 +1033,24 @@ pub struct UsageAuditListQuery {
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct UsageAuditKeywordSearchQuery {
#[serde(default)]
pub slow_threshold_ms: Option<u64>,
#[serde(default)]
pub endpoint_kind: Option<String>,
#[serde(default)]
pub request_type: Option<String>,
#[serde(default)]
pub has_format_conversion: Option<bool>,
#[serde(default)]
pub provider_id: Option<String>,
#[serde(default)]
pub api_key_id: Option<String>,
#[serde(default)]
pub request_id: Option<String>,
#[serde(default)]
pub attribution_kind: Option<String>,
#[serde(default)]
pub actor_user_id: Option<String>,
pub created_from_unix_secs: Option<u64>,
pub created_until_unix_secs: Option<u64>,
pub user_id: Option<String>,
@@ -1740,6 +1776,42 @@ pub enum StoredUsageBodyPayload {
#[async_trait]
pub trait UsageReadRepository: Send + Sync {
async fn query_dashboard_summary(
&self,
_query: &super::UsageDashboardAnalyticsQuery,
) -> Result<super::StoredDashboardSummary, crate::DataLayerError> {
Err(crate::DataLayerError::UnexpectedValue(
"dashboard summary repository unavailable".into(),
))
}
async fn query_dashboard_analytics(
&self,
_query: &super::UsageDashboardAnalyticsQuery,
) -> Result<super::StoredUsageDashboardAnalytics, crate::DataLayerError> {
Err(crate::DataLayerError::UnexpectedValue(
"dashboard analytics repository unavailable".into(),
))
}
async fn summarize_health_observations(
&self,
_query: &super::HealthObservationQuery,
) -> Result<super::HealthObservationSummary, crate::DataLayerError> {
Err(crate::DataLayerError::UnexpectedValue(
"health observations repository unavailable".into(),
))
}
async fn query_usage_analytics(
&self,
_query: &super::UsageAnalyticsQuery,
) -> Result<super::StoredUsageAnalytics, crate::DataLayerError> {
Err(crate::DataLayerError::UnexpectedValue(
"usage analytics repository unavailable".into(),
))
}
async fn find_by_id(
&self,
id: &str,
@@ -105,6 +105,12 @@ impl WalletReadSnapshot {
.as_deref()
.is_none_or(|expected| wallet.status == expected)
})
.filter(|wallet| {
query
.user_id
.as_deref()
.is_none_or(|expected| wallet.user_id.as_deref() == Some(expected))
})
.filter(|wallet| match query.owner_type.as_deref() {
Some("user") => wallet.user_id.is_some(),
Some("api_key") => wallet.api_key_id.is_some(),
@@ -114,6 +114,7 @@ impl StoredWalletSnapshot {
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub struct AdminWalletListQuery {
pub user_id: Option<String>,
pub status: Option<String>,
pub owner_type: Option<String>,
pub limit: usize,