mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 10:57:03 +08:00
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:
@@ -8,7 +8,8 @@ use uuid::Uuid;
|
||||
use crate::DataLayerError;
|
||||
use aether_data_contracts::repository::announcements::{
|
||||
AnnouncementListQuery, AnnouncementReadRepository, AnnouncementWriteRepository,
|
||||
CreateAnnouncementRecord, StoredAnnouncement, StoredAnnouncementPage, UpdateAnnouncementRecord,
|
||||
CreateAnnouncementRecord, StoredAnnouncement, StoredAnnouncementPage, StoredUserAnnouncement,
|
||||
StoredUserAnnouncementPage, UpdateAnnouncementRecord, UserAnnouncementListQuery,
|
||||
};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -105,6 +106,65 @@ impl AnnouncementReadRepository for InMemoryAnnouncementReadRepository {
|
||||
Ok(StoredAnnouncementPage { items, total })
|
||||
}
|
||||
|
||||
async fn list_user_announcements(
|
||||
&self,
|
||||
user_id: &str,
|
||||
query: &UserAnnouncementListQuery,
|
||||
) -> Result<StoredUserAnnouncementPage, DataLayerError> {
|
||||
query.validate()?;
|
||||
let announcements = self
|
||||
.announcements
|
||||
.read()
|
||||
.expect("announcement repository lock");
|
||||
let reads = self
|
||||
.announcement_reads
|
||||
.read()
|
||||
.expect("announcement reads repository lock");
|
||||
let mut unread_count = 0;
|
||||
let mut items = announcements
|
||||
.iter()
|
||||
.filter(|announcement| {
|
||||
announcement.is_active
|
||||
&& announcement
|
||||
.start_time_unix_secs
|
||||
.is_none_or(|value| value <= query.now_unix_secs)
|
||||
&& announcement
|
||||
.end_time_unix_secs
|
||||
.is_none_or(|value| value >= query.now_unix_secs)
|
||||
})
|
||||
.filter_map(|announcement| {
|
||||
let is_read = reads.contains(&(user_id.to_string(), announcement.id.clone()));
|
||||
if !is_read {
|
||||
unread_count += 1;
|
||||
}
|
||||
(!query.unread_only || !is_read).then_some((announcement, is_read))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by(|(left, _), (right, _)| {
|
||||
right
|
||||
.is_pinned
|
||||
.cmp(&left.is_pinned)
|
||||
.then_with(|| right.priority.cmp(&left.priority))
|
||||
.then_with(|| right.created_at_unix_ms.cmp(&left.created_at_unix_ms))
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
let total = items.len() as u64;
|
||||
let items = items
|
||||
.into_iter()
|
||||
.skip(query.offset)
|
||||
.take(query.limit)
|
||||
.map(|(announcement, is_read)| StoredUserAnnouncement {
|
||||
announcement: announcement.clone(),
|
||||
is_read,
|
||||
})
|
||||
.collect();
|
||||
Ok(StoredUserAnnouncementPage {
|
||||
items,
|
||||
total,
|
||||
unread_count,
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_unread_active_announcements(
|
||||
&self,
|
||||
user_id: &str,
|
||||
@@ -294,9 +354,95 @@ mod tests {
|
||||
use super::InMemoryAnnouncementReadRepository;
|
||||
use crate::repository::announcements::{
|
||||
AnnouncementReadRepository, AnnouncementWriteRepository, CreateAnnouncementRecord,
|
||||
StoredAnnouncement, UpdateAnnouncementRecord,
|
||||
StoredAnnouncement, UpdateAnnouncementRecord, UserAnnouncementListQuery,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn personal_announcement_page_preserves_global_unread_and_visibility() {
|
||||
let now = 1_800_000_000;
|
||||
let announcements = [
|
||||
("pinned", true, true, 0, None, None),
|
||||
("normal-a", true, false, 20, Some(now), Some(now)),
|
||||
("normal-b", true, false, 20, None, None),
|
||||
("draft", false, false, 99, None, None),
|
||||
("future", true, false, 99, Some(now + 1), None),
|
||||
("expired", true, false, 99, None, Some(now - 1)),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(id, active, pinned, priority, start, end)| {
|
||||
StoredAnnouncement::new(
|
||||
id.into(),
|
||||
id.into(),
|
||||
"content".into(),
|
||||
"info".into(),
|
||||
priority,
|
||||
active,
|
||||
pinned,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
start,
|
||||
end,
|
||||
now,
|
||||
now,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
let repository = InMemoryAnnouncementReadRepository::seed_with_reads(
|
||||
announcements,
|
||||
[("reader".into(), "pinned".into())],
|
||||
);
|
||||
let mut query = UserAnnouncementListQuery {
|
||||
unread_only: false,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
now_unix_secs: now as u64,
|
||||
};
|
||||
let first = repository
|
||||
.list_user_announcements("reader", &query)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((first.total, first.unread_count), (3, 2));
|
||||
assert_eq!(first.items[0].announcement.id, "pinned");
|
||||
assert!(first.items[0].is_read);
|
||||
query.unread_only = true;
|
||||
query.offset = 1;
|
||||
let second = repository
|
||||
.list_user_announcements("reader", &query)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((second.total, second.unread_count), (2, 2));
|
||||
assert_eq!(second.items[0].announcement.id, "normal-b");
|
||||
assert!(!second.items[0].is_read);
|
||||
query.offset = i64::MAX as usize;
|
||||
let empty = repository
|
||||
.list_user_announcements("reader", &query)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(empty.items.is_empty());
|
||||
assert_eq!((empty.total, empty.unread_count), (2, 2));
|
||||
let other = repository
|
||||
.list_user_announcements("other", &query)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((other.total, other.unread_count), (3, 3));
|
||||
repository
|
||||
.mark_announcement_as_read("reader", "normal-a", now as u64)
|
||||
.await
|
||||
.unwrap();
|
||||
query.offset = 0;
|
||||
let after_read = repository
|
||||
.list_user_announcements("reader", &query)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((after_read.total, after_read.unread_count), (1, 1));
|
||||
query.limit = 101;
|
||||
assert!(repository
|
||||
.list_user_announcements("reader", &query)
|
||||
.await
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_seeded_announcements() {
|
||||
let repository = InMemoryAnnouncementReadRepository::seed(vec![StoredAnnouncement::new(
|
||||
|
||||
@@ -2,7 +2,8 @@ mod memory;
|
||||
|
||||
pub use aether_data_contracts::repository::announcements::{
|
||||
AnnouncementListQuery, AnnouncementReadRepository, AnnouncementWriteRepository,
|
||||
CreateAnnouncementRecord, StoredAnnouncement, StoredAnnouncementPage, UpdateAnnouncementRecord,
|
||||
CreateAnnouncementRecord, StoredAnnouncement, StoredAnnouncementPage, StoredUserAnnouncement,
|
||||
StoredUserAnnouncementPage, UpdateAnnouncementRecord, UserAnnouncementListQuery,
|
||||
};
|
||||
#[cfg(feature = "postgres")]
|
||||
pub use aether_data_postgres::SqlxAnnouncementReadRepository;
|
||||
|
||||
@@ -229,6 +229,24 @@ impl InMemoryAuthApiKeySnapshotRepository {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn standalone_flags(&self) -> BTreeMap<String, bool> {
|
||||
let index = self
|
||||
.index
|
||||
.read()
|
||||
.expect("auth api key snapshot repository lock");
|
||||
index
|
||||
.export_by_api_key_id
|
||||
.iter()
|
||||
.map(|(id, record)| (id.clone(), record.is_standalone))
|
||||
.chain(
|
||||
index
|
||||
.by_api_key_id
|
||||
.iter()
|
||||
.map(|(id, snapshot)| (id.clone(), snapshot.api_key_is_standalone)),
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn snapshot_lookup_count(&self, api_key_id: &str) -> usize {
|
||||
self.index
|
||||
.read()
|
||||
@@ -2101,6 +2119,43 @@ mod tests {
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_flags_cover_export_only_keys_and_remove_deleted_keys() {
|
||||
let mut standalone = sample_snapshot("standalone-key", "user-1");
|
||||
standalone.api_key_is_standalone = true;
|
||||
let repository = InMemoryAuthApiKeySnapshotRepository::seed([
|
||||
(None, sample_snapshot("member-key", "user-1")),
|
||||
(None, standalone),
|
||||
]);
|
||||
let mut export = repository
|
||||
.list_export_api_keys_by_ids(&["standalone-key".into()])
|
||||
.await
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
export.api_key_id = "export-only-key".into();
|
||||
let repository = repository.with_export_records([export]);
|
||||
|
||||
assert_eq!(
|
||||
repository.standalone_flags(),
|
||||
std::collections::BTreeMap::from([
|
||||
("member-key".into(), false),
|
||||
("standalone-key".into(), true),
|
||||
("export-only-key".into(), true),
|
||||
])
|
||||
);
|
||||
assert!(repository
|
||||
.delete_standalone_api_key("standalone-key")
|
||||
.await
|
||||
.unwrap());
|
||||
assert_eq!(
|
||||
repository.standalone_flags(),
|
||||
std::collections::BTreeMap::from([
|
||||
("member-key".into(), false),
|
||||
("export-only-key".into(), true),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_auth_snapshot_by_all_supported_keys() {
|
||||
let repository = InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
|
||||
@@ -16,6 +16,7 @@ type BillingContextMap = BTreeMap<BillingContextKey, StoredBillingModelContext>;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryBillingReadRepository {
|
||||
provider_expenses: RwLock<BTreeMap<String, super::ProviderExpenseRecord>>,
|
||||
by_key: RwLock<BillingContextMap>,
|
||||
gateway_configs_by_provider: RwLock<BTreeMap<String, PaymentGatewayConfigRecord>>,
|
||||
billing_plans_by_id: RwLock<BTreeMap<String, BillingPlanRecord>>,
|
||||
@@ -23,6 +24,20 @@ pub struct InMemoryBillingReadRepository {
|
||||
}
|
||||
|
||||
impl InMemoryBillingReadRepository {
|
||||
pub fn seed_user_plan_entitlements(
|
||||
items: impl IntoIterator<Item = UserPlanEntitlementRecord>,
|
||||
) -> Self {
|
||||
Self {
|
||||
entitlements_by_id: RwLock::new(
|
||||
items
|
||||
.into_iter()
|
||||
.map(|item| (item.id.clone(), item))
|
||||
.collect(),
|
||||
),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn seed<I>(items: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = StoredBillingModelContext>,
|
||||
@@ -39,6 +54,7 @@ impl InMemoryBillingReadRepository {
|
||||
);
|
||||
}
|
||||
Self {
|
||||
provider_expenses: RwLock::default(),
|
||||
by_key: RwLock::new(by_key),
|
||||
gateway_configs_by_provider: RwLock::new(BTreeMap::new()),
|
||||
billing_plans_by_id: RwLock::new(BTreeMap::new()),
|
||||
@@ -325,6 +341,68 @@ impl BillingReadRepository for InMemoryBillingReadRepository {
|
||||
Ok(AdminBillingMutationOutcome::Applied(record))
|
||||
}
|
||||
|
||||
async fn list_provider_expenses(
|
||||
&self,
|
||||
query: &super::ProviderExpenseQuery,
|
||||
) -> Result<Option<super::ProviderExpensePage>, DataLayerError> {
|
||||
let guard = self
|
||||
.provider_expenses
|
||||
.read()
|
||||
.expect("provider expense store should lock");
|
||||
super::provider_expense_memory_page(guard.values().cloned(), query).map(Some)
|
||||
}
|
||||
async fn create_provider_expense(
|
||||
&self,
|
||||
input: &super::ProviderExpenseInput,
|
||||
) -> Result<AdminBillingMutationOutcome<super::ProviderExpenseRecord>, DataLayerError> {
|
||||
if let Err(detail) = input.validate() {
|
||||
return Ok(AdminBillingMutationOutcome::Invalid(detail));
|
||||
}
|
||||
let mut guard = self
|
||||
.provider_expenses
|
||||
.write()
|
||||
.expect("provider expense store should lock");
|
||||
if let Some(existing) = guard
|
||||
.values()
|
||||
.find(|r| r.entry.client_request_id == input.client_request_id)
|
||||
{
|
||||
return Ok(if existing.entry.same_request_as(input) {
|
||||
AdminBillingMutationOutcome::Applied(existing.clone())
|
||||
} else {
|
||||
AdminBillingMutationOutcome::Invalid(
|
||||
"client_request_id was already used for another expense".into(),
|
||||
)
|
||||
});
|
||||
}
|
||||
let record = super::ProviderExpenseRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
entry: input.clone(),
|
||||
created_at_unix_ms: chrono::Utc::now().timestamp_millis().max(0) as u64,
|
||||
voided_at_unix_ms: None,
|
||||
voided_by: None,
|
||||
};
|
||||
guard.insert(record.id.clone(), record.clone());
|
||||
Ok(AdminBillingMutationOutcome::Applied(record))
|
||||
}
|
||||
async fn void_provider_expense(
|
||||
&self,
|
||||
id: &str,
|
||||
operator: Option<&str>,
|
||||
) -> Result<AdminBillingMutationOutcome<super::ProviderExpenseRecord>, DataLayerError> {
|
||||
let mut guard = self
|
||||
.provider_expenses
|
||||
.write()
|
||||
.expect("provider expense store should lock");
|
||||
let Some(record) = guard.get_mut(id) else {
|
||||
return Ok(AdminBillingMutationOutcome::NotFound);
|
||||
};
|
||||
if record.voided_at_unix_ms.is_none() {
|
||||
record.voided_at_unix_ms = Some(chrono::Utc::now().timestamp_millis().max(0) as u64);
|
||||
record.voided_by = operator.map(str::to_owned);
|
||||
}
|
||||
Ok(AdminBillingMutationOutcome::Applied(record.clone()))
|
||||
}
|
||||
|
||||
async fn list_billing_plans(
|
||||
&self,
|
||||
include_disabled: bool,
|
||||
@@ -435,6 +513,15 @@ impl BillingReadRepository for InMemoryBillingReadRepository {
|
||||
async fn list_user_plan_entitlements(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, DataLayerError> {
|
||||
self.list_user_plan_entitlements_with_history(user_id, false)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_user_plan_entitlements_with_history(
|
||||
&self,
|
||||
user_id: &str,
|
||||
include_inactive: bool,
|
||||
) -> Result<Option<Vec<UserPlanEntitlementRecord>>, DataLayerError> {
|
||||
let now = current_unix_secs();
|
||||
let mut items = self
|
||||
@@ -444,12 +531,12 @@ impl BillingReadRepository for InMemoryBillingReadRepository {
|
||||
.values()
|
||||
.filter(|item| {
|
||||
item.user_id == user_id
|
||||
&& item.status == "active"
|
||||
&& item.expires_at_unix_secs > now
|
||||
&& (include_inactive
|
||||
|| (item.status == "active" && item.expires_at_unix_secs > now))
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
items.sort_by_key(|item| item.expires_at_unix_secs);
|
||||
items.sort_by_key(|item| (item.expires_at_unix_secs, item.created_at_unix_secs));
|
||||
Ok(Some(items))
|
||||
}
|
||||
|
||||
@@ -592,6 +679,59 @@ mod tests {
|
||||
.expect("billing context should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_user_plan_entitlements_history_includes_inactive_only_for_selected_user() {
|
||||
let repository = InMemoryBillingReadRepository::default();
|
||||
let now = super::current_unix_secs();
|
||||
for (id, user_id, status, expires_at) in [
|
||||
("active", "user-1", "active", now + 3600),
|
||||
("expired", "user-1", "active", now - 60),
|
||||
("revoked", "user-1", "revoked", now + 3600),
|
||||
("replaced", "user-1", "replaced", now + 3600),
|
||||
("another-user", "user-2", "revoked", now + 3600),
|
||||
] {
|
||||
repository.entitlements_by_id.write().unwrap().insert(
|
||||
id.to_string(),
|
||||
super::UserPlanEntitlementRecord {
|
||||
id: id.to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
plan_id: "plan-1".to_string(),
|
||||
payment_order_id: format!("order-{id}"),
|
||||
status: status.to_string(),
|
||||
starts_at_unix_secs: now - 120,
|
||||
expires_at_unix_secs: expires_at,
|
||||
entitlements_snapshot: json!([]),
|
||||
created_at_unix_secs: now - 120,
|
||||
updated_at_unix_secs: now - 60,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let active = repository
|
||||
.list_user_plan_entitlements("user-1")
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(active.len(), 1);
|
||||
assert_eq!(active[0].id, "active");
|
||||
let explicit_active = repository
|
||||
.list_user_plan_entitlements_with_history("user-1", false)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(active, explicit_active);
|
||||
let history = repository
|
||||
.list_user_plan_entitlements_with_history("user-1", true)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(history.len(), 4);
|
||||
assert!(history.iter().all(|item| item.user_id == "user-1"));
|
||||
for expected in ["active", "expired", "revoked", "replaced"] {
|
||||
assert!(history.iter().any(|item| item.id == expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_back_to_provider_without_key_scope() {
|
||||
let repository = InMemoryBillingReadRepository::seed(vec![sample_context()]);
|
||||
@@ -779,3 +919,108 @@ mod tests {
|
||||
assert_eq!(after, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod provider_expense_tests {
|
||||
use super::*;
|
||||
use crate::repository::billing::{ProviderExpenseInput, ProviderExpenseQuery};
|
||||
fn input() -> ProviderExpenseInput {
|
||||
ProviderExpenseInput {
|
||||
client_request_id: uuid::Uuid::new_v4().to_string(),
|
||||
provider_id: "provider-1".into(),
|
||||
provider_name: "Supplier".into(),
|
||||
kind: "recharge".into(),
|
||||
amount: "12.34000000".into(),
|
||||
currency: "USD".into(),
|
||||
paid_at_unix_ms: 1000,
|
||||
period_start_unix_ms: None,
|
||||
period_end_unix_ms: None,
|
||||
note: Some("test".into()),
|
||||
external_reference: None,
|
||||
created_by: Some("admin-1".into()),
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn provider_expense_retries_and_void_are_idempotent_without_erasing_audit() {
|
||||
let repo = InMemoryBillingReadRepository::default();
|
||||
let input = input();
|
||||
let AdminBillingMutationOutcome::Applied(first) =
|
||||
repo.create_provider_expense(&input).await.unwrap()
|
||||
else {
|
||||
panic!("expected record")
|
||||
};
|
||||
let mut retried = input.clone();
|
||||
retried.provider_name = "Renamed supplier".into();
|
||||
let AdminBillingMutationOutcome::Applied(second) =
|
||||
repo.create_provider_expense(&retried).await.unwrap()
|
||||
else {
|
||||
panic!("expected retry")
|
||||
};
|
||||
assert_eq!(first, second);
|
||||
retried.amount = "99".into();
|
||||
assert!(matches!(
|
||||
repo.create_provider_expense(&retried).await.unwrap(),
|
||||
AdminBillingMutationOutcome::Invalid(_)
|
||||
));
|
||||
let query = ProviderExpenseQuery {
|
||||
from_unix_ms: 0,
|
||||
to_unix_ms: 2000,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
};
|
||||
assert_eq!(
|
||||
repo.list_provider_expenses(&query)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.total,
|
||||
1
|
||||
);
|
||||
let AdminBillingMutationOutcome::Applied(voided) = repo
|
||||
.void_provider_expense(&first.id, Some("admin-2"))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected void")
|
||||
};
|
||||
let AdminBillingMutationOutcome::Applied(again) = repo
|
||||
.void_provider_expense(&first.id, Some("admin-3"))
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("expected retry void")
|
||||
};
|
||||
assert_eq!(voided, again);
|
||||
assert_eq!(again.voided_by.as_deref(), Some("admin-2"));
|
||||
let page = repo.list_provider_expenses(&query).await.unwrap().unwrap();
|
||||
assert_eq!(page.total, 0);
|
||||
assert!(page.totals.is_empty());
|
||||
let AdminBillingMutationOutcome::Applied(after_void) =
|
||||
repo.create_provider_expense(&input).await.unwrap()
|
||||
else {
|
||||
panic!("expected original tombstone")
|
||||
};
|
||||
assert_eq!(after_void, again);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn provider_expense_duplicate_submissions_record_once() {
|
||||
let repo = InMemoryBillingReadRepository::default();
|
||||
let input = input();
|
||||
let (a, b) = tokio::join!(
|
||||
repo.create_provider_expense(&input),
|
||||
repo.create_provider_expense(&input)
|
||||
);
|
||||
let (AdminBillingMutationOutcome::Applied(a), AdminBillingMutationOutcome::Applied(b)) =
|
||||
(a.unwrap(), b.unwrap())
|
||||
else {
|
||||
panic!("expected records")
|
||||
};
|
||||
assert_eq!(a.id, b.id);
|
||||
let mut invalid = input.clone();
|
||||
invalid.period_start_unix_ms = Some(100);
|
||||
assert!(matches!(
|
||||
repo.create_provider_expense(&invalid).await.unwrap(),
|
||||
AdminBillingMutationOutcome::Invalid(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use super::{
|
||||
ReserveUsagePolicyCostInput, ReserveUsagePolicyCostOutcome, ReserveUsagePolicyRequestInput,
|
||||
ReserveUsagePolicyRequestOutcome, SettlementWriteRepository, StoredUsagePolicyCostReservation,
|
||||
StoredUsagePolicyRequestAdmission, StoredUsageSettlement, UsagePolicyCostReservationState,
|
||||
UsagePolicyRequestAdmissionState, UsageSettlementInput, SETTLEMENT_EPSILON_USD,
|
||||
UsagePolicyRequestAdmissionState, UsageSettlementInput,
|
||||
};
|
||||
use crate::repository::wallet::{InMemoryWalletRepository, StoredWalletSnapshot};
|
||||
use crate::DataLayerError;
|
||||
@@ -511,9 +511,7 @@ impl SettlementWriteRepository for InMemorySettlementRepository {
|
||||
settlement.wallet_recharge_balance_after = Some(wallet.balance);
|
||||
settlement.wallet_gift_balance_after = Some(wallet.gift_balance);
|
||||
settlement.wallet_balance_after = Some(wallet.balance + wallet.gift_balance);
|
||||
} else if final_billing_status == "settled"
|
||||
&& billable_cost_usd > SETTLEMENT_EPSILON_USD
|
||||
{
|
||||
} else if final_billing_status == "settled" && billable_cost_usd > 0.0 {
|
||||
final_billing_status = "insufficient_quota".to_string();
|
||||
settlement.billing_status = final_billing_status.clone();
|
||||
}
|
||||
|
||||
@@ -44,17 +44,33 @@ use super::{
|
||||
use crate::repository::auth::InMemoryAuthApiKeySnapshotRepository;
|
||||
use crate::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
||||
use crate::DataLayerError;
|
||||
mod analytics;
|
||||
mod dashboard_summary;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryUsageReadRepository {
|
||||
dashboard_projection: RwLock<dashboard_summary::DashboardProjection>,
|
||||
by_request_id: RwLock<BTreeMap<String, StoredRequestUsageAudit>>,
|
||||
detached_bodies: RwLock<BTreeMap<String, Value>>,
|
||||
provider_usage_windows: RwLock<Vec<StoredProviderUsageWindow>>,
|
||||
auth_api_keys: Option<Arc<InMemoryAuthApiKeySnapshotRepository>>,
|
||||
provider_catalog: Option<Arc<InMemoryProviderCatalogReadRepository>>,
|
||||
analytics_users: RwLock<Vec<aether_data_contracts::repository::users::StoredUserSummary>>,
|
||||
analytics_candidates:
|
||||
RwLock<Vec<aether_data_contracts::repository::candidates::StoredRequestCandidate>>,
|
||||
analytics_allocations: RwLock<
|
||||
BTreeMap<String, aether_data_contracts::repository::usage::UsageAnalyticsAllocation>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl InMemoryUsageReadRepository {
|
||||
fn analytics_key_flags(&self) -> BTreeMap<String, bool> {
|
||||
self.auth_api_keys
|
||||
.as_ref()
|
||||
.map(|repository| repository.standalone_flags())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn seed<I>(items: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = StoredRequestUsageAudit>,
|
||||
@@ -66,11 +82,15 @@ impl InMemoryUsageReadRepository {
|
||||
by_request_id.insert(item.request_id.clone(), item);
|
||||
}
|
||||
Self {
|
||||
dashboard_projection: Default::default(),
|
||||
by_request_id: RwLock::new(by_request_id),
|
||||
detached_bodies: RwLock::new(BTreeMap::new()),
|
||||
provider_usage_windows: RwLock::new(Vec::new()),
|
||||
auth_api_keys: None,
|
||||
provider_catalog: None,
|
||||
analytics_users: Default::default(),
|
||||
analytics_candidates: Default::default(),
|
||||
analytics_allocations: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,11 +139,15 @@ impl InMemoryUsageReadRepository {
|
||||
by_request_id.insert(request_id, item);
|
||||
}
|
||||
Self {
|
||||
dashboard_projection: Default::default(),
|
||||
by_request_id: RwLock::new(by_request_id),
|
||||
detached_bodies: RwLock::new(detached_bodies),
|
||||
provider_usage_windows: RwLock::new(Vec::new()),
|
||||
auth_api_keys: None,
|
||||
provider_catalog: None,
|
||||
analytics_users: Default::default(),
|
||||
analytics_candidates: Default::default(),
|
||||
analytics_allocations: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,11 +156,15 @@ impl InMemoryUsageReadRepository {
|
||||
I: IntoIterator<Item = StoredProviderUsageWindow>,
|
||||
{
|
||||
Self {
|
||||
dashboard_projection: self.dashboard_projection,
|
||||
by_request_id: self.by_request_id,
|
||||
detached_bodies: self.detached_bodies,
|
||||
provider_usage_windows: RwLock::new(items.into_iter().collect()),
|
||||
auth_api_keys: self.auth_api_keys,
|
||||
provider_catalog: self.provider_catalog,
|
||||
analytics_users: self.analytics_users,
|
||||
analytics_candidates: self.analytics_candidates,
|
||||
analytics_allocations: self.analytics_allocations,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +272,48 @@ fn usage_has_admin_unknown_model_or_provider(item: &StoredRequestUsageAudit) ->
|
||||
usage_admin_unknown_label(&item.model) || usage_admin_unknown_label(&item.provider_name)
|
||||
}
|
||||
|
||||
fn usage_matches_list_query(item: &StoredRequestUsageAudit, query: &UsageAuditListQuery) -> bool {
|
||||
fn usage_matches_list_query(
|
||||
item: &StoredRequestUsageAudit,
|
||||
query: &UsageAuditListQuery,
|
||||
keys: &BTreeMap<String, bool>,
|
||||
) -> bool {
|
||||
if query
|
||||
.provider_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| item.provider_id.as_ref() != Some(id))
|
||||
|| query
|
||||
.endpoint_kind
|
||||
.as_ref()
|
||||
.is_some_and(|value| item.endpoint_kind.as_ref() != Some(value))
|
||||
|| query
|
||||
.request_type
|
||||
.as_ref()
|
||||
.is_some_and(|value| item.request_type.as_ref() != Some(value))
|
||||
|| query
|
||||
.slow_threshold_ms
|
||||
.is_some_and(|value| item.response_time_ms.is_none_or(|latency| latency < value))
|
||||
|| query
|
||||
.has_format_conversion
|
||||
.is_some_and(|value| item.has_format_conversion != value)
|
||||
|| query
|
||||
.api_key_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| item.api_key_id.as_ref() != Some(id))
|
||||
|| query
|
||||
.request_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| item.request_id != *id)
|
||||
|| query
|
||||
.attribution_kind
|
||||
.as_deref()
|
||||
.is_some_and(|kind| analytics::attribution(item, keys) != kind)
|
||||
|| query
|
||||
.actor_user_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| analytics::actor(item, keys) != Some(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// The field is historically named `created_at_unix_ms`, but usage audit rows
|
||||
// across gateway handlers, SQL repositories and tests are stored as epoch seconds.
|
||||
if let Some(created_from_unix_secs) = query.created_from_unix_secs {
|
||||
@@ -332,7 +401,45 @@ fn usage_matches_list_query(item: &StoredRequestUsageAudit, query: &UsageAuditLi
|
||||
fn usage_matches_keyword_search_query(
|
||||
item: &StoredRequestUsageAudit,
|
||||
query: &UsageAuditKeywordSearchQuery,
|
||||
keys: &BTreeMap<String, bool>,
|
||||
) -> bool {
|
||||
if query
|
||||
.provider_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| item.provider_id.as_ref() != Some(id))
|
||||
|| query
|
||||
.endpoint_kind
|
||||
.as_ref()
|
||||
.is_some_and(|value| item.endpoint_kind.as_ref() != Some(value))
|
||||
|| query
|
||||
.request_type
|
||||
.as_ref()
|
||||
.is_some_and(|value| item.request_type.as_ref() != Some(value))
|
||||
|| query
|
||||
.slow_threshold_ms
|
||||
.is_some_and(|value| item.response_time_ms.is_none_or(|latency| latency < value))
|
||||
|| query
|
||||
.has_format_conversion
|
||||
.is_some_and(|value| item.has_format_conversion != value)
|
||||
|| query
|
||||
.api_key_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| item.api_key_id.as_ref() != Some(id))
|
||||
|| query
|
||||
.request_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| item.request_id != *id)
|
||||
|| query
|
||||
.attribution_kind
|
||||
.as_deref()
|
||||
.is_some_and(|kind| analytics::attribution(item, keys) != kind)
|
||||
|| query
|
||||
.actor_user_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| analytics::actor(item, keys) != Some(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(created_from_unix_secs) = query.created_from_unix_secs {
|
||||
if item.created_at_unix_ms < created_from_unix_secs {
|
||||
return false;
|
||||
@@ -1127,6 +1234,38 @@ fn usage_provider_aggregation_identity(
|
||||
|
||||
#[async_trait]
|
||||
impl UsageReadRepository for InMemoryUsageReadRepository {
|
||||
async fn query_dashboard_summary(
|
||||
&self,
|
||||
query: &aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery,
|
||||
) -> Result<aether_data_contracts::repository::usage::StoredDashboardSummary, DataLayerError> {
|
||||
self.dashboard_summary_query(query)
|
||||
}
|
||||
|
||||
async fn query_dashboard_analytics(
|
||||
&self,
|
||||
query: &aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery,
|
||||
) -> Result<
|
||||
aether_data_contracts::repository::usage::StoredUsageDashboardAnalytics,
|
||||
DataLayerError,
|
||||
> {
|
||||
self.dashboard_analytics_query(query)
|
||||
}
|
||||
|
||||
async fn query_usage_analytics(
|
||||
&self,
|
||||
query: &aether_data_contracts::repository::usage::UsageAnalyticsQuery,
|
||||
) -> Result<aether_data_contracts::repository::usage::StoredUsageAnalytics, DataLayerError>
|
||||
{
|
||||
self.analytics_query(query)
|
||||
}
|
||||
|
||||
async fn summarize_health_observations(
|
||||
&self,
|
||||
query: &aether_data_contracts::repository::usage::HealthObservationQuery,
|
||||
) -> Result<aether_data_contracts::repository::usage::HealthObservationSummary, DataLayerError>
|
||||
{
|
||||
self.health_observations(query)
|
||||
}
|
||||
async fn find_by_id(
|
||||
&self,
|
||||
id: &str,
|
||||
@@ -1205,12 +1344,13 @@ impl UsageReadRepository for InMemoryUsageReadRepository {
|
||||
&self,
|
||||
query: &UsageAuditListQuery,
|
||||
) -> Result<Vec<StoredRequestUsageAudit>, DataLayerError> {
|
||||
let keys = self.analytics_key_flags();
|
||||
let mut items: Vec<_> = self
|
||||
.by_request_id
|
||||
.read()
|
||||
.expect("usage repository lock")
|
||||
.values()
|
||||
.filter(|item| usage_matches_list_query(item, query))
|
||||
.filter(|item| usage_matches_list_query(item, query, &keys))
|
||||
.cloned()
|
||||
.collect();
|
||||
sort_usage_items(&mut items, query.newest_first);
|
||||
@@ -1231,12 +1371,13 @@ impl UsageReadRepository for InMemoryUsageReadRepository {
|
||||
&self,
|
||||
query: &UsageAuditKeywordSearchQuery,
|
||||
) -> Result<Vec<StoredRequestUsageAudit>, DataLayerError> {
|
||||
let keys = self.analytics_key_flags();
|
||||
let mut items: Vec<_> = self
|
||||
.by_request_id
|
||||
.read()
|
||||
.expect("usage repository lock")
|
||||
.values()
|
||||
.filter(|item| usage_matches_keyword_search_query(item, query))
|
||||
.filter(|item| usage_matches_keyword_search_query(item, query, &keys))
|
||||
.cloned()
|
||||
.collect();
|
||||
sort_usage_items(&mut items, query.newest_first);
|
||||
@@ -1254,12 +1395,13 @@ impl UsageReadRepository for InMemoryUsageReadRepository {
|
||||
}
|
||||
|
||||
async fn count_usage_audits(&self, query: &UsageAuditListQuery) -> Result<u64, DataLayerError> {
|
||||
let keys = self.analytics_key_flags();
|
||||
Ok(self
|
||||
.by_request_id
|
||||
.read()
|
||||
.expect("usage repository lock")
|
||||
.values()
|
||||
.filter(|item| usage_matches_list_query(item, query))
|
||||
.filter(|item| usage_matches_list_query(item, query, &keys))
|
||||
.count() as u64)
|
||||
}
|
||||
|
||||
@@ -1267,12 +1409,13 @@ impl UsageReadRepository for InMemoryUsageReadRepository {
|
||||
&self,
|
||||
query: &UsageAuditKeywordSearchQuery,
|
||||
) -> Result<u64, DataLayerError> {
|
||||
let keys = self.analytics_key_flags();
|
||||
Ok(self
|
||||
.by_request_id
|
||||
.read()
|
||||
.expect("usage repository lock")
|
||||
.values()
|
||||
.filter(|item| usage_matches_keyword_search_query(item, query))
|
||||
.filter(|item| usage_matches_keyword_search_query(item, query, &keys))
|
||||
.count() as u64)
|
||||
}
|
||||
|
||||
@@ -3281,6 +3424,8 @@ impl UsageWriteRepository for InMemoryUsageReadRepository {
|
||||
finalized_at_unix_secs: usage.finalized_at_unix_secs,
|
||||
};
|
||||
|
||||
self.dashboard_projection.write().expect("dashboard projection lock")
|
||||
.record(&stored, &self.analytics_key_flags());
|
||||
by_request_id.insert(stored.request_id.clone(), stored.clone());
|
||||
if let Some(auth_api_keys) = self.auth_api_keys.as_ref() {
|
||||
let before_contribution = existing.as_ref().and_then(api_key_usage_contribution);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
use super::{
|
||||
analytics, usage_cache_creation_tokens, usage_total_input_context, usage_total_tokens,
|
||||
InMemoryUsageReadRepository, StoredRequestUsageAudit,
|
||||
};
|
||||
use aether_data_contracts::{repository::usage::*, DataLayerError};
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct DashboardProjection {
|
||||
pub since: DateTime<Utc>,
|
||||
entries: BTreeMap<String, Contribution>,
|
||||
}
|
||||
impl Default for DashboardProjection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
since: Utc::now(),
|
||||
entries: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct Contribution {
|
||||
at: DateTime<Utc>,
|
||||
actor: Option<String>,
|
||||
metrics: DashboardSummaryMetrics,
|
||||
billable_units: Option<i128>,
|
||||
}
|
||||
impl DashboardProjection {
|
||||
pub fn record(&mut self, row: &StoredRequestUsageAudit, keys: &BTreeMap<String, bool>) {
|
||||
let Some(at) = DateTime::from_timestamp(row.created_at_unix_ms as i64, 0) else {
|
||||
return;
|
||||
};
|
||||
if at < self.since {
|
||||
return;
|
||||
}
|
||||
if row
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.pointer("/analytics_attribution/record_kind"))
|
||||
.and_then(|v| v.as_str())
|
||||
== Some("session")
|
||||
{
|
||||
self.entries.remove(&row.request_id);
|
||||
return;
|
||||
}
|
||||
let available = |key| {
|
||||
row.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get(key))
|
||||
.and_then(|v| v.as_bool())
|
||||
!= Some(false)
|
||||
};
|
||||
let usage = available(USAGE_AVAILABLE_METADATA_KEY);
|
||||
let priced =
|
||||
available(USAGE_PRICING_AVAILABLE_METADATA_KEY) && row.billing_status == "settled";
|
||||
let stream = row
|
||||
.request_metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("upstream_is_stream"))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(row.is_stream);
|
||||
let metrics = DashboardSummaryMetrics {
|
||||
request_count: 1,
|
||||
input_tokens: if usage { row.input_tokens } else { 0 },
|
||||
output_tokens: if usage { row.output_tokens } else { 0 },
|
||||
total_tokens: if usage {
|
||||
if row.total_tokens > 0 {
|
||||
row.total_tokens
|
||||
} else {
|
||||
usage_total_tokens(row)
|
||||
}
|
||||
} else {
|
||||
0
|
||||
},
|
||||
usage_available_count: u64::from(usage),
|
||||
pricing_available_count: u64::from(priced),
|
||||
cache_read_tokens: if usage {
|
||||
row.cache_read_input_tokens
|
||||
} else {
|
||||
0
|
||||
},
|
||||
cache_creation_tokens: if usage {
|
||||
usage_cache_creation_tokens(row)
|
||||
} else {
|
||||
0
|
||||
},
|
||||
cache_input_tokens: if usage {
|
||||
usage_total_input_context(row)
|
||||
} else {
|
||||
0
|
||||
},
|
||||
first_byte_sum_ms: row.first_byte_time_ms.unwrap_or(0) as f64,
|
||||
first_byte_sample_count: u64::from(row.first_byte_time_ms.is_some()),
|
||||
response_sum_ms: row.response_time_ms.unwrap_or(0) as f64,
|
||||
response_sample_count: u64::from(row.response_time_ms.is_some()),
|
||||
stream_requests: u64::from(stream),
|
||||
standard_requests: u64::from(!stream),
|
||||
..Default::default()
|
||||
};
|
||||
self.entries.insert(
|
||||
row.request_id.clone(),
|
||||
Contribution {
|
||||
at,
|
||||
actor: analytics::actor(row, keys).map(str::to_owned),
|
||||
metrics,
|
||||
billable_units: priced
|
||||
.then(|| (row.actual_total_cost_usd * 100_000_000.0).round() as i128),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
fn sum_metrics<'a>(rows: impl Iterator<Item = &'a Contribution>) -> DashboardSummaryMetrics {
|
||||
let mut sum = DashboardSummaryMetrics::default();
|
||||
let mut users = BTreeSet::new();
|
||||
let mut units = 0_i128;
|
||||
for row in rows {
|
||||
let m = &row.metrics;
|
||||
sum.request_count += m.request_count;
|
||||
sum.input_tokens += m.input_tokens;
|
||||
sum.output_tokens += m.output_tokens;
|
||||
sum.total_tokens += m.total_tokens;
|
||||
sum.usage_available_count += m.usage_available_count;
|
||||
sum.pricing_available_count += m.pricing_available_count;
|
||||
sum.cache_read_tokens += m.cache_read_tokens;
|
||||
sum.cache_creation_tokens += m.cache_creation_tokens;
|
||||
sum.cache_input_tokens += m.cache_input_tokens;
|
||||
sum.first_byte_sum_ms += m.first_byte_sum_ms;
|
||||
sum.first_byte_sample_count += m.first_byte_sample_count;
|
||||
sum.response_sum_ms += m.response_sum_ms;
|
||||
sum.response_sample_count += m.response_sample_count;
|
||||
sum.stream_requests += m.stream_requests;
|
||||
sum.standard_requests += m.standard_requests;
|
||||
units += row.billable_units.unwrap_or(0);
|
||||
if let Some(actor) = row.actor.as_deref() {
|
||||
users.insert(actor);
|
||||
}
|
||||
}
|
||||
sum.active_users = users.len() as u64;
|
||||
if sum.request_count == 0 || sum.pricing_available_count > 0 {
|
||||
sum.billable_amount = Some(format!(
|
||||
"{}{}.{:08}",
|
||||
if units < 0 { "-" } else { "" },
|
||||
units.abs() / 100_000_000,
|
||||
units.abs() % 100_000_000
|
||||
));
|
||||
}
|
||||
sum
|
||||
}
|
||||
impl InMemoryUsageReadRepository {
|
||||
/// Test/embedded initialization boundary; seeded older audit rows stay excluded.
|
||||
pub fn with_dashboard_stats_since(self, since: DateTime<Utc>) -> Self {
|
||||
let keys = self.analytics_key_flags();
|
||||
let mut projection = self
|
||||
.dashboard_projection
|
||||
.write()
|
||||
.expect("dashboard projection lock");
|
||||
projection.since = since;
|
||||
projection.entries.clear();
|
||||
for row in self
|
||||
.by_request_id
|
||||
.read()
|
||||
.expect("usage repository lock")
|
||||
.values()
|
||||
{
|
||||
projection.record(row, &keys);
|
||||
}
|
||||
drop(projection);
|
||||
self
|
||||
}
|
||||
pub(super) fn dashboard_summary_query(
|
||||
&self,
|
||||
query: &UsageDashboardAnalyticsQuery,
|
||||
) -> Result<StoredDashboardSummary, DataLayerError> {
|
||||
query.validate()?;
|
||||
let projection = self.dashboard_projection.read().map_err(|_| {
|
||||
DataLayerError::UnexpectedValue("dashboard projection lock poisoned".into())
|
||||
})?;
|
||||
let now = Utc::now();
|
||||
let today_from = query.today_start(now)?.max(projection.since);
|
||||
let tz = query
|
||||
.timezone
|
||||
.parse::<chrono_tz::Tz>()
|
||||
.map_err(|_| DataLayerError::InvalidInput("invalid timezone".into()))?;
|
||||
let rows = || projection.entries.values().filter(|row| row.at < now);
|
||||
let today = sum_metrics(rows().filter(|row| row.at >= today_from));
|
||||
let total = sum_metrics(rows());
|
||||
let mut days = BTreeMap::<NaiveDate, u64>::new();
|
||||
for row in rows() {
|
||||
*days
|
||||
.entry(row.at.with_timezone(&tz).date_naive())
|
||||
.or_default() += 1;
|
||||
}
|
||||
let active_days = days.len() as u64;
|
||||
let local_today = now.with_timezone(&tz).date_naive();
|
||||
let consecutive_active_days =
|
||||
dashboard_consecutive_active_days(days.keys().copied(), local_today);
|
||||
let earliest_day = local_today - chrono::Duration::days(364);
|
||||
let activity_days = days
|
||||
.into_iter()
|
||||
.filter(|(day, _)| day >= &earliest_day)
|
||||
.map(|(date, requests)| DashboardActivityDay {
|
||||
date: date.to_string(),
|
||||
requests,
|
||||
})
|
||||
.collect();
|
||||
let users = self
|
||||
.analytics_users
|
||||
.read()
|
||||
.map_err(|_| DataLayerError::UnexpectedValue("users lock poisoned".into()))?;
|
||||
Ok(StoredDashboardSummary {
|
||||
stats_since: projection.since.to_rfc3339(),
|
||||
generated_at: now.to_rfc3339(),
|
||||
timezone: query.timezone.clone(),
|
||||
today_from: today_from.to_rfc3339(),
|
||||
window_seconds: (now - today_from).num_milliseconds().max(0) as f64 / 1000.0,
|
||||
today,
|
||||
total,
|
||||
users: DashboardUserCounts {
|
||||
total: users.iter().filter(|user| !user.is_deleted).count() as u64,
|
||||
..Default::default()
|
||||
},
|
||||
active_days,
|
||||
consecutive_active_days,
|
||||
activity_days,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,486 @@ use aether_data_contracts::repository::usage::{
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_model_performance_merges_provider_samples_without_pagination() {
|
||||
use aether_data_contracts::repository::usage::*;
|
||||
let at = chrono::DateTime::parse_from_rfc3339("2026-09-12T10:05:00Z").unwrap();
|
||||
let mut records = Vec::new();
|
||||
for (index, provider, first_byte, response_time, output_tokens) in [
|
||||
(0, "provider-a", 100, 1100, 100),
|
||||
(1, "provider-a", 300, 1300, 200),
|
||||
(2, "provider-b", 500, 2500, 400),
|
||||
] {
|
||||
let mut row = sample_usage(&format!("model-sample-{index}"), at.timestamp());
|
||||
row.provider_id = Some(provider.into());
|
||||
row.model = "shared-model".into();
|
||||
row.target_model = Some(format!("{provider}-deployment"));
|
||||
row.first_byte_time_ms = Some(first_byte);
|
||||
row.response_time_ms = Some(response_time);
|
||||
row.output_tokens = output_tokens;
|
||||
row.is_stream = true;
|
||||
records.push(row);
|
||||
}
|
||||
let mut failed = sample_usage("model-failed", at.timestamp());
|
||||
failed.model = "shared-model".into();
|
||||
failed.status = "failed".into();
|
||||
failed.first_byte_time_ms = None;
|
||||
failed.response_time_ms = None;
|
||||
records.push(failed);
|
||||
let mut pending = sample_usage("model-pending", at.timestamp());
|
||||
pending.model = "pending-model".into();
|
||||
pending.status = "pending".into();
|
||||
pending.first_byte_time_ms = None;
|
||||
pending.response_time_ms = None;
|
||||
records.push(pending);
|
||||
|
||||
let repo = InMemoryUsageReadRepository::seed(records);
|
||||
let query = UsageAnalyticsQuery {
|
||||
from_unix_ms: (at - chrono::Duration::minutes(5)).timestamp_millis() as u64,
|
||||
to_unix_ms: (at + chrono::Duration::minutes(55)).timestamp_millis() as u64,
|
||||
timezone: "UTC".into(),
|
||||
view: UsageAnalyticsView::Performance,
|
||||
limit: 1,
|
||||
offset: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let result = repo.query_usage_analytics(&query).await.unwrap();
|
||||
assert_eq!(result.model_rows.len(), 2);
|
||||
assert_eq!(result.model_rows[0].id.as_deref(), Some("shared-model"));
|
||||
assert!(result
|
||||
.model_rows
|
||||
.iter()
|
||||
.all(|row| row.bucket_start.is_none()));
|
||||
let metrics = &result.model_rows[0].metrics;
|
||||
assert_eq!(metrics.request_count, 4);
|
||||
assert_eq!(metrics.successful_request_count, 3);
|
||||
assert_eq!(metrics.failed_request_count, 1);
|
||||
assert_eq!(metrics.first_byte_sample_count, 3);
|
||||
assert_eq!(metrics.first_byte_sum_ms, 900.0);
|
||||
assert_eq!(metrics.latency_sample_count, 3);
|
||||
assert_eq!(metrics.latency_sum_ms, 4900.0);
|
||||
assert_eq!(metrics.output_tps_sample_count, 3);
|
||||
assert_eq!(metrics.output_tps_sum, 500.0);
|
||||
assert_eq!(result.model_rows[1].metrics.first_byte_sample_count, 0);
|
||||
assert_eq!(result.model_rows[1].metrics.in_flight_request_count, 1);
|
||||
assert_eq!(
|
||||
result
|
||||
.model_rows
|
||||
.iter()
|
||||
.map(|row| row.metrics.request_count)
|
||||
.sum::<u64>(),
|
||||
result.summary.request_count
|
||||
);
|
||||
|
||||
let filtered = repo
|
||||
.query_usage_analytics(&UsageAnalyticsQuery {
|
||||
model: Some("shared-model".into()),
|
||||
..query
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(filtered.model_rows, vec![result.model_rows[0].clone()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_memory_chart_hour_buckets_are_utc_in_half_hour_zones() {
|
||||
use aether_data_contracts::repository::usage::*;
|
||||
let at = chrono::DateTime::parse_from_rfc3339("2026-09-12T10:05:00Z").unwrap();
|
||||
let repo = InMemoryUsageReadRepository::seed([sample_usage("hour-zone", at.timestamp())]);
|
||||
let query = UsageAnalyticsQuery {
|
||||
from_unix_ms: (at - chrono::Duration::minutes(5)).timestamp_millis() as u64,
|
||||
to_unix_ms: (at + chrono::Duration::minutes(55)).timestamp_millis() as u64,
|
||||
timezone: "Asia/Kolkata".into(),
|
||||
view: UsageAnalyticsView::DashboardCharts,
|
||||
granularity: UsageAnalyticsGranularity::Hour,
|
||||
limit: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let charts = repo.query_usage_analytics(&query).await.unwrap();
|
||||
assert_eq!(charts.summary.request_count, 1);
|
||||
assert_eq!(charts.rows.len(), 1);
|
||||
assert_eq!(charts.rows[0].metrics.request_count, 1);
|
||||
assert_eq!(
|
||||
charts.rows[0].bucket_start,
|
||||
charts.model_rows[0].bucket_start
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_memory_chart_days_survive_skipped_midnight() {
|
||||
use aether_data_contracts::repository::usage::*;
|
||||
let moments = [
|
||||
"2026-09-05T12:00:00Z",
|
||||
"2026-09-06T12:00:00Z",
|
||||
"2026-09-07T12:00:00Z",
|
||||
];
|
||||
let repo = InMemoryUsageReadRepository::seed(moments.map(|moment| {
|
||||
sample_usage(
|
||||
moment,
|
||||
chrono::DateTime::parse_from_rfc3339(moment)
|
||||
.unwrap()
|
||||
.timestamp(),
|
||||
)
|
||||
}));
|
||||
let query = UsageAnalyticsQuery {
|
||||
from_unix_ms: chrono::DateTime::parse_from_rfc3339("2026-09-05T04:00:00Z")
|
||||
.unwrap()
|
||||
.timestamp_millis() as u64,
|
||||
to_unix_ms: chrono::DateTime::parse_from_rfc3339("2026-09-08T03:00:00Z")
|
||||
.unwrap()
|
||||
.timestamp_millis() as u64,
|
||||
timezone: "America/Santiago".into(),
|
||||
view: UsageAnalyticsView::DashboardCharts,
|
||||
limit: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let charts = repo.query_usage_analytics(&query).await.unwrap();
|
||||
assert_eq!(charts.summary.request_count, 3);
|
||||
assert_eq!(charts.rows.len(), 3);
|
||||
assert_eq!(charts.model_rows.len(), 3);
|
||||
for (series, model) in charts.rows.iter().zip(&charts.model_rows) {
|
||||
assert_eq!(series.metrics.request_count, 1);
|
||||
assert_eq!(series.bucket_start, model.bucket_start);
|
||||
assert_eq!(
|
||||
series.metrics.billable_amount,
|
||||
model.metrics.billable_amount
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_memory_dashboard_keeps_all_history_and_chart_dimensions() {
|
||||
use aether_data_contracts::repository::usage::*;
|
||||
let now = chrono::Utc::now();
|
||||
let mut old = sample_usage(
|
||||
"old-dashboard",
|
||||
(now - chrono::Duration::days(800)).timestamp(),
|
||||
);
|
||||
old.actual_total_cost_usd = 2.0;
|
||||
old.model = "old-model".into();
|
||||
let mut current = sample_usage("current-dashboard", now.timestamp());
|
||||
current.actual_total_cost_usd = 0.5;
|
||||
current.model = "new-model".into();
|
||||
let repo = InMemoryUsageReadRepository::seed([old, current]);
|
||||
let dashboard = repo
|
||||
.query_dashboard_analytics(&UsageDashboardAnalyticsQuery {
|
||||
timezone: "Asia/Shanghai".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(dashboard.today.summary.request_count, 1);
|
||||
assert_eq!(dashboard.total.summary.request_count, 2);
|
||||
assert_eq!(
|
||||
dashboard.today.summary.billable_amount.as_deref(),
|
||||
Some("0.50000000")
|
||||
);
|
||||
assert_eq!(
|
||||
dashboard.total.summary.billable_amount.as_deref(),
|
||||
Some("2.50000000")
|
||||
);
|
||||
assert_eq!(dashboard.today.read_revision, dashboard.total.read_revision);
|
||||
assert_eq!(dashboard.history_complete, None);
|
||||
let charts = repo
|
||||
.query_usage_analytics(&UsageAnalyticsQuery {
|
||||
from_unix_ms: (now - chrono::Duration::days(1)).timestamp_millis() as u64,
|
||||
to_unix_ms: (now + chrono::Duration::seconds(1)).timestamp_millis() as u64,
|
||||
timezone: "Asia/Shanghai".into(),
|
||||
view: UsageAnalyticsView::DashboardCharts,
|
||||
limit: 1,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(charts.model_rows.len(), 1);
|
||||
assert_eq!(charts.model_rows[0].id.as_deref(), Some("new-model"));
|
||||
assert_eq!(charts.provider_rows.len(), 1);
|
||||
assert_eq!(
|
||||
charts.model_rows[0].metrics.billable_amount,
|
||||
charts.summary.billable_amount
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_memory_dashboard_total_keeps_card_coverage_without_historical_diagnostics() {
|
||||
use aether_data_contracts::repository::usage::*;
|
||||
let now = chrono::Utc::now();
|
||||
let mut known = sample_usage("dashboard-total-known", now.timestamp());
|
||||
known.request_metadata = Some(json!({"analytics_attribution":{"is_standalone":false}}));
|
||||
let mut missing = sample_usage(
|
||||
"dashboard-total-missing",
|
||||
(now - chrono::Duration::days(800)).timestamp(),
|
||||
);
|
||||
missing.billing_status = "pending".into();
|
||||
missing.request_metadata =
|
||||
Some(json!({"usage_available":false,"usage_pricing_available":false}));
|
||||
let mut session = sample_usage("dashboard-total-session", now.timestamp());
|
||||
session.request_metadata = Some(json!({"analytics_attribution":{"record_kind":"session"}}));
|
||||
let future = sample_usage(
|
||||
"dashboard-total-future",
|
||||
(now + chrono::Duration::days(1)).timestamp(),
|
||||
);
|
||||
let repo = InMemoryUsageReadRepository::seed([known, missing, session, future])
|
||||
.with_analytics_allocations([UsageAnalyticsAllocation {
|
||||
request_id: "dashboard-total-known".into(),
|
||||
complete: true,
|
||||
wallet_debit_amount: Some("0.18000000".into()),
|
||||
..Default::default()
|
||||
}]);
|
||||
let dashboard = repo
|
||||
.query_dashboard_analytics(&UsageDashboardAnalyticsQuery {
|
||||
timezone: "UTC".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let total = dashboard.total.summary;
|
||||
assert_eq!(total.request_count, 2);
|
||||
assert_eq!(total.total_tokens, 150);
|
||||
assert_eq!(total.billable_amount.as_deref(), Some("0.18000000"));
|
||||
assert_eq!(total.usage_available_count, 1);
|
||||
assert_eq!(total.pricing_available_count, 1);
|
||||
assert_eq!(total.settled_count, 1);
|
||||
assert_eq!(total.allocation_available_count, 1);
|
||||
assert!(total.latency_p95_ms.is_none());
|
||||
assert!(total.wallet_debit_amount.is_none());
|
||||
assert_eq!(dashboard.today.summary.latency_p95_ms, Some(420.0));
|
||||
assert_eq!(dashboard.today.summary.successful_request_count, 1);
|
||||
assert_eq!(
|
||||
dashboard.today.summary.wallet_debit_amount.as_deref(),
|
||||
Some("0.18000000")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_memory_employee_roster_and_allocations_are_global() {
|
||||
use aether_data_contracts::repository::usage::*;
|
||||
use aether_data_contracts::repository::users::StoredUserSummary;
|
||||
let mut usage = sample_usage("overview-request", 1_700_000_000);
|
||||
usage.user_id = Some("alice".into());
|
||||
usage.request_metadata = Some(json!({"analytics_attribution":{"is_standalone":false}}));
|
||||
usage.billing_status = "settled".into();
|
||||
usage.actual_total_cost_usd = 0.00000001;
|
||||
let repo = InMemoryUsageReadRepository::seed([usage])
|
||||
.with_analytics_users([
|
||||
StoredUserSummary::new(
|
||||
"alice".into(),
|
||||
"Alice".into(),
|
||||
None,
|
||||
"user".into(),
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
StoredUserSummary::new(
|
||||
"zero".into(),
|
||||
"Zero".into(),
|
||||
None,
|
||||
"user".into(),
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
])
|
||||
.with_analytics_allocations([UsageAnalyticsAllocation {
|
||||
request_id: "overview-request".into(),
|
||||
quota_covered_amount: Some("0.00000000".into()),
|
||||
wallet_consumed_amount: Some("0.00000001".into()),
|
||||
wallet_debit_amount: Some("0.00000000".into()),
|
||||
complete: true,
|
||||
..Default::default()
|
||||
}]);
|
||||
let mut query = UsageAnalyticsQuery {
|
||||
from_unix_ms: 1_700_000_000_000,
|
||||
to_unix_ms: 1_700_000_060_000,
|
||||
timezone: "UTC".into(),
|
||||
view: UsageAnalyticsView::Users,
|
||||
limit: 1,
|
||||
descending: true,
|
||||
..Default::default()
|
||||
};
|
||||
let first = repo.query_usage_analytics(&query).await.unwrap();
|
||||
assert_eq!(first.total, 2);
|
||||
assert_eq!(first.users[0].user_id, "alice");
|
||||
assert_eq!(first.user_summary.as_ref().unwrap().user_count, 2);
|
||||
assert_eq!(first.user_summary.as_ref().unwrap().active_user_count, 1);
|
||||
assert!(first.user_finance_summary.is_none());
|
||||
assert!(first.user_payments.is_none());
|
||||
assert!(first.users[0].finance.is_none());
|
||||
assert_eq!(
|
||||
first.summary.wallet_consumed_amount.as_deref(),
|
||||
Some("0.00000001")
|
||||
);
|
||||
assert_eq!(
|
||||
first.summary.wallet_debit_amount.as_deref(),
|
||||
Some("0.00000000")
|
||||
);
|
||||
query.offset = 1;
|
||||
let second = repo.query_usage_analytics(&query).await.unwrap();
|
||||
assert_eq!(second.users[0].user_id, "zero");
|
||||
assert_eq!(second.users[0].metrics.request_count, 0);
|
||||
assert_eq!(second.user_summary, first.user_summary);
|
||||
let searched = repo
|
||||
.query_usage_analytics(&UsageAnalyticsQuery {
|
||||
search: Some("Zero".into()),
|
||||
offset: 0,
|
||||
..query.clone()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(searched.user_summary.as_ref().unwrap().user_count, 1);
|
||||
assert_eq!(searched.user_summary.as_ref().unwrap().active_user_count, 0);
|
||||
assert_eq!(searched.summary.request_count, 0);
|
||||
query.from_unix_ms = query.to_unix_ms;
|
||||
query.to_unix_ms += 60_000;
|
||||
let outside = repo.query_usage_analytics(&query).await.unwrap();
|
||||
assert_eq!(outside.summary.request_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_memory_employee_is_grouped_by_account_owner() {
|
||||
use aether_data_contracts::repository::{usage::*, users::StoredUserSummary};
|
||||
let mut usage = sample_usage("trusted-request", 1_700_000_000);
|
||||
usage.user_id = Some("owner".into());
|
||||
usage.request_metadata =
|
||||
Some(json!({"analytics_attribution":{"is_standalone":false,"actor_user_id":"actor"}}));
|
||||
let repo = InMemoryUsageReadRepository::seed([usage]).with_analytics_users(
|
||||
["owner", "actor"].map(|id| {
|
||||
StoredUserSummary::new(id.into(), id.into(), None, "user".into(), true, false).unwrap()
|
||||
}),
|
||||
);
|
||||
let query = UsageAnalyticsQuery {
|
||||
from_unix_ms: 1_700_000_000_000,
|
||||
to_unix_ms: 1_700_000_060_000,
|
||||
timezone: "UTC".into(),
|
||||
view: UsageAnalyticsView::Users,
|
||||
attribution_kind: Some("employee".into()),
|
||||
has_usage: Some(true),
|
||||
limit: 100,
|
||||
..Default::default()
|
||||
};
|
||||
let result = repo.query_usage_analytics(&query).await.unwrap();
|
||||
assert_eq!(result.total, 1);
|
||||
assert_eq!(result.users[0].user_id, "owner");
|
||||
let detail = repo
|
||||
.query_usage_analytics(&UsageAnalyticsQuery {
|
||||
actor_user_id: Some("owner".into()),
|
||||
..query
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(detail.users[0], result.users[0]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_memory_legacy_requests_use_existing_key_account_flags() {
|
||||
use aether_data_contracts::repository::{usage::*, users::StoredUserSummary};
|
||||
let snapshots = [("member-key", false), ("standalone-key", true)].map(|(id, standalone)| {
|
||||
(
|
||||
None,
|
||||
StoredAuthApiKeySnapshot::new(
|
||||
"user-1".into(),
|
||||
"alice".into(),
|
||||
None,
|
||||
"user".into(),
|
||||
"local".into(),
|
||||
true,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
id.into(),
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
standalone,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
});
|
||||
let keys = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(snapshots));
|
||||
let rows = ["member-key", "standalone-key", "deleted-key"].map(|id| {
|
||||
let mut usage = sample_usage(id, 1_700_000_000);
|
||||
usage.api_key_id = Some(id.into());
|
||||
usage.request_metadata = None;
|
||||
usage
|
||||
});
|
||||
let repo = InMemoryUsageReadRepository::seed(rows)
|
||||
.with_auth_api_key_repository(keys)
|
||||
.with_analytics_users([StoredUserSummary::new(
|
||||
"user-1".into(),
|
||||
"alice".into(),
|
||||
None,
|
||||
"user".into(),
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.unwrap()]);
|
||||
let mut query = UsageAnalyticsQuery {
|
||||
from_unix_ms: 1_700_000_000_000,
|
||||
to_unix_ms: 1_700_000_060_000,
|
||||
timezone: "UTC".into(),
|
||||
view: UsageAnalyticsView::Consumption,
|
||||
limit: 100,
|
||||
..Default::default()
|
||||
};
|
||||
let result = repo.query_usage_analytics(&query).await.unwrap();
|
||||
assert_eq!(result.summary.request_count, 3);
|
||||
assert_eq!(result.summary.usage_active_users, 1);
|
||||
assert_eq!(result.summary.trusted_attribution_count, 1);
|
||||
for (key, kind, source, user) in [
|
||||
("member-key", "employee", "user_account", Some("user-1")),
|
||||
("standalone-key", "standalone", "standalone_key", None),
|
||||
("deleted-key", "unknown", "unknown", None),
|
||||
] {
|
||||
let row = result
|
||||
.consumption
|
||||
.iter()
|
||||
.find(|row| row.request_id == key)
|
||||
.unwrap();
|
||||
assert_eq!(row.attribution_kind, kind);
|
||||
assert_eq!(row.attribution_source, source);
|
||||
assert_eq!(row.user_id.as_deref(), user);
|
||||
assert_eq!(row.credential_owner_id.as_deref(), Some("user-1"));
|
||||
}
|
||||
query.view = UsageAnalyticsView::Users;
|
||||
query.attribution_kind = Some("employee".into());
|
||||
let employees = repo.query_usage_analytics(&query).await.unwrap();
|
||||
assert_eq!(employees.users[0].metrics.request_count, 1);
|
||||
assert_eq!(employees.summary.request_count, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn overview_memory_health_does_not_treat_upstream_cancellation_as_client() {
|
||||
use aether_data_contracts::repository::usage::*;
|
||||
let mut upstream = sample_usage("upstream-cancel", 1_700_000_000);
|
||||
upstream.status = "cancelled".into();
|
||||
upstream.request_metadata =
|
||||
Some(json!({"analytics_failure":{"origin":"upstream","reason":"provider_cancelled"}}));
|
||||
let mut client = upstream.clone();
|
||||
client.request_id = "client-cancel".into();
|
||||
client.request_metadata =
|
||||
Some(json!({"analytics_failure":{"origin":"client","reason":"downstream_disconnect"}}));
|
||||
let repo = InMemoryUsageReadRepository::seed([upstream, client]);
|
||||
let summary = repo
|
||||
.summarize_health_observations(&HealthObservationQuery {
|
||||
from_unix_ms: 1_700_000_000_000,
|
||||
to_unix_ms: 1_700_000_060_000,
|
||||
object_kind: HealthObservationObjectKind::Model,
|
||||
object_values: None,
|
||||
segments: 4,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(summary.overall.request_count, 2);
|
||||
assert_eq!(summary.overall.service_failed_count, 1);
|
||||
assert_eq!(summary.overall.excluded_count, 1);
|
||||
}
|
||||
|
||||
fn sample_usage(request_id: &str, created_at_unix_ms: i64) -> StoredRequestUsageAudit {
|
||||
StoredRequestUsageAudit::new(
|
||||
"usage-1".to_string(),
|
||||
@@ -2763,3 +3243,99 @@ async fn summarize_usage_provider_performance_computes_tps_and_top_provider_time
|
||||
assert_eq!(without_timeline.providers, summary.providers);
|
||||
assert!(without_timeline.timeline.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn future_dashboard_summary_excludes_old_rows_and_preserves_canonical_cache_samples() {
|
||||
use aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery;
|
||||
let now = chrono::Utc::now();
|
||||
let since = now - chrono::Duration::minutes(5);
|
||||
let mut included = sample_usage(
|
||||
"future-summary",
|
||||
(now - chrono::Duration::seconds(1)).timestamp(),
|
||||
);
|
||||
included.api_key_id = None;
|
||||
included.api_format = Some("claude:messages".into());
|
||||
included.endpoint_api_format = Some("claude:messages".into());
|
||||
included.input_tokens = 80;
|
||||
included.output_tokens = 20;
|
||||
included.total_tokens = 150;
|
||||
included.cache_read_input_tokens = 40;
|
||||
included.cache_creation_input_tokens = 10;
|
||||
included.response_time_ms = Some(800);
|
||||
included.first_byte_time_ms = Some(100);
|
||||
included.is_stream = false;
|
||||
included.request_metadata = Some(json!({"upstream_is_stream": true}));
|
||||
included.actual_total_cost_usd = 0.12345678;
|
||||
included.billing_status = "settled".into();
|
||||
let mut excluded = included.clone();
|
||||
excluded.request_id = "before-activation".into();
|
||||
excluded.created_at_unix_ms = (since - chrono::Duration::days(500)).timestamp() as u64;
|
||||
let repo =
|
||||
InMemoryUsageReadRepository::seed([included, excluded]).with_dashboard_stats_since(since);
|
||||
let first = repo
|
||||
.query_dashboard_summary(&UsageDashboardAnalyticsQuery {
|
||||
timezone: "Asia/Kathmandu".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(first.total.request_count, 1);
|
||||
assert_eq!(first.total.total_tokens, 150);
|
||||
assert_eq!(first.total.billable_amount.as_deref(), Some("0.12345678"));
|
||||
assert_eq!(first.today.cache_input_tokens, 130);
|
||||
assert_eq!(first.today.cache_read_tokens, 40);
|
||||
assert_eq!(first.today.first_byte_sample_count, 1);
|
||||
assert_eq!(first.today.response_sample_count, 1);
|
||||
assert_eq!(first.today.stream_requests, 1);
|
||||
assert_eq!(first.today.standard_requests, 0);
|
||||
assert_eq!(first.active_days, 1);
|
||||
assert_eq!(first.consecutive_active_days, 1);
|
||||
assert_eq!(
|
||||
first.activity_days.iter().map(|d| d.requests).sum::<u64>(),
|
||||
1
|
||||
);
|
||||
// Audit retention does not own the additive projection.
|
||||
repo.by_request_id.write().unwrap().clear();
|
||||
let retained = repo
|
||||
.query_dashboard_summary(&UsageDashboardAnalyticsQuery {
|
||||
timezone: "Asia/Kathmandu".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(retained.total, first.total);
|
||||
assert_eq!(retained.stats_since, since.to_rfc3339());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn future_dashboard_summary_streak_uses_local_days_before_heatmap_truncation() {
|
||||
use aether_data_contracts::repository::usage::UsageDashboardAnalyticsQuery;
|
||||
use chrono::TimeZone;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let tz = chrono_tz::Asia::Kathmandu;
|
||||
let today = now.with_timezone(&tz).date_naive();
|
||||
let rows = (1..=400).map(|offset| {
|
||||
let day = today - chrono::Duration::days(offset);
|
||||
let at = tz
|
||||
.from_local_datetime(&day.and_hms_opt(12, 0, 0).unwrap())
|
||||
.single()
|
||||
.unwrap();
|
||||
sample_usage(&format!("streak-{offset}"), at.timestamp())
|
||||
});
|
||||
let repo = InMemoryUsageReadRepository::seed(rows)
|
||||
.with_dashboard_stats_since(now - chrono::Duration::days(401));
|
||||
let summary = repo
|
||||
.query_dashboard_summary(&UsageDashboardAnalyticsQuery {
|
||||
timezone: tz.to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(summary.today.request_count, 0);
|
||||
assert_eq!(summary.active_days, 400);
|
||||
assert_eq!(summary.consecutive_active_days, 400);
|
||||
assert_eq!(summary.activity_days.len(), 364);
|
||||
assert_eq!(
|
||||
summary.activity_days.last().unwrap().date,
|
||||
today.pred_opt().unwrap().to_string()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -831,6 +831,12 @@ impl WalletReadRepository for InMemoryWalletRepository {
|
||||
.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(),
|
||||
@@ -3418,6 +3424,7 @@ mod tests {
|
||||
|
||||
let page = repository
|
||||
.list_admin_wallets(&AdminWalletListQuery {
|
||||
user_id: None,
|
||||
status: Some("active".to_string()),
|
||||
owner_type: Some("api_key".to_string()),
|
||||
limit: 1,
|
||||
@@ -3430,6 +3437,31 @@ mod tests {
|
||||
assert_eq!(page.items.len(), 1);
|
||||
assert_eq!(page.items[0].id, "wallet-3");
|
||||
assert_eq!(page.items[0].updated_at_unix_secs, Some(110));
|
||||
|
||||
let query = AdminWalletListQuery {
|
||||
user_id: Some("user-2".to_string()),
|
||||
owner_type: Some("user".to_string()),
|
||||
limit: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let selected = repository.list_admin_wallets(&query).await.unwrap();
|
||||
assert_eq!(selected.total, 1);
|
||||
assert_eq!(selected.items[0].id, "wallet-2");
|
||||
let missing = repository
|
||||
.list_admin_wallets(&AdminWalletListQuery {
|
||||
user_id: Some("missing-user".to_string()),
|
||||
..query.clone()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing.total, 0);
|
||||
assert!(missing.items.is_empty());
|
||||
let beyond_page = repository
|
||||
.list_admin_wallets(&AdminWalletListQuery { offset: 1, ..query })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(beyond_page.total, 1);
|
||||
assert!(beyond_page.items.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user