mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +08:00
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.
319 lines
12 KiB
Rust
319 lines
12 KiB
Rust
use aether_data_contracts::repository::usage::{
|
|
UsageAnalyticsGranularity, UsageAnalyticsGroupBy, UsageAnalyticsQuery, UsageAnalyticsSort,
|
|
UsageAnalyticsView,
|
|
};
|
|
use chrono::{DateTime, Utc};
|
|
use std::collections::BTreeMap;
|
|
|
|
pub const OVERVIEW_EXPORT_LIMIT: u32 = 10_000;
|
|
|
|
#[derive(Debug)]
|
|
pub struct OverviewRequest {
|
|
pub query: UsageAnalyticsQuery,
|
|
pub csv: bool,
|
|
pub amount_basis: String,
|
|
}
|
|
|
|
pub fn parse_overview_query(
|
|
raw: Option<&str>,
|
|
view: UsageAnalyticsView,
|
|
) -> Result<OverviewRequest, String> {
|
|
let mut params = BTreeMap::new();
|
|
for (key, value) in url::form_urlencoded::parse(raw.unwrap_or_default().as_bytes()) {
|
|
if params
|
|
.insert(key.into_owned(), value.into_owned())
|
|
.is_some()
|
|
{
|
|
return Err("duplicate query parameters are not supported".into());
|
|
}
|
|
}
|
|
let from = timestamp(&mut params, "from")?;
|
|
let to = timestamp(&mut params, "to")?;
|
|
let timezone = params.remove("timezone").unwrap_or_else(|| "UTC".into());
|
|
let csv = match params.remove("format").as_deref() {
|
|
None | Some("json") => false,
|
|
Some("csv")
|
|
if matches!(
|
|
view,
|
|
UsageAnalyticsView::Users
|
|
| UsageAnalyticsView::Consumption
|
|
| UsageAnalyticsView::Breakdown
|
|
) =>
|
|
{
|
|
true
|
|
}
|
|
_ => return Err("format is not supported for this report".into()),
|
|
};
|
|
let limit = number(&mut params, "limit", 25_u32)?;
|
|
let offset = number(&mut params, "offset", 0_u64)?;
|
|
let payment_limit = params
|
|
.remove("payment_limit")
|
|
.map(|value| {
|
|
value
|
|
.parse::<u32>()
|
|
.map_err(|_| "invalid payment_limit".to_string())
|
|
})
|
|
.transpose()?;
|
|
let payment_offset = params
|
|
.remove("payment_offset")
|
|
.map(|value| {
|
|
value
|
|
.parse::<u64>()
|
|
.map_err(|_| "invalid payment_offset".to_string())
|
|
})
|
|
.transpose()?;
|
|
if limit == 0 || limit > 100 {
|
|
return Err("limit must be between 1 and 100".into());
|
|
}
|
|
if csv && offset != 0 {
|
|
return Err("CSV exports apply to the complete filter; offset must be zero".into());
|
|
}
|
|
let granularity = match params.remove("granularity").as_deref() {
|
|
None | Some("day") => UsageAnalyticsGranularity::Day,
|
|
Some("hour") => UsageAnalyticsGranularity::Hour,
|
|
_ => return Err("granularity must be hour or day".into()),
|
|
};
|
|
if granularity == UsageAnalyticsGranularity::Hour && to.saturating_sub(from) > 31 * 86_400_000 {
|
|
return Err("hourly reports are limited to 31 days".into());
|
|
}
|
|
let group_by = match params.remove("group_by").as_deref() {
|
|
None | Some("model") => UsageAnalyticsGroupBy::Model,
|
|
Some("provider") => UsageAnalyticsGroupBy::Provider,
|
|
Some("api_key") => UsageAnalyticsGroupBy::ApiKey,
|
|
Some("attribution") => UsageAnalyticsGroupBy::Attribution,
|
|
Some("api_format") => UsageAnalyticsGroupBy::ApiFormat,
|
|
Some("request_type") => UsageAnalyticsGroupBy::RequestType,
|
|
_ => return Err("unsupported group_by dimension".into()),
|
|
};
|
|
let sort = match params.remove("sort").as_deref() {
|
|
None | Some("requests") | Some("request_count") => UsageAnalyticsSort::Requests,
|
|
Some("billable_amount") => UsageAnalyticsSort::BillableAmount,
|
|
Some("total_tokens") => UsageAnalyticsSort::Tokens,
|
|
Some("active_days") => UsageAnalyticsSort::ActiveDays,
|
|
Some("started_at") => UsageAnalyticsSort::StartedAt,
|
|
Some("last_used") | Some("last_used_at") => UsageAnalyticsSort::LastUsed,
|
|
Some("username") => UsageAnalyticsSort::Username,
|
|
_ => return Err("unsupported sort field".into()),
|
|
};
|
|
let descending = match params.remove("order").as_deref() {
|
|
None | Some("desc") => true,
|
|
Some("asc") => false,
|
|
_ => return Err("order must be asc or desc".into()),
|
|
};
|
|
let user_is_active = match params.remove("account_status").as_deref() {
|
|
None | Some("all") => None,
|
|
Some("active") | Some("enabled") => Some(true),
|
|
Some("inactive") | Some("disabled") => Some(false),
|
|
_ => return Err("unsupported account_status".into()),
|
|
};
|
|
let has_usage = match params.remove("usage_status").as_deref() {
|
|
None | Some("all") => None,
|
|
Some("used") | Some("active") => Some(true),
|
|
Some("unused") | Some("inactive") => Some(false),
|
|
_ => return Err("unsupported usage_status".into()),
|
|
};
|
|
if view != UsageAnalyticsView::Users && (user_is_active.is_some() || has_usage.is_some()) {
|
|
return Err(
|
|
"account_status and usage_status are only supported by employee reports".into(),
|
|
);
|
|
}
|
|
let amount_basis = params
|
|
.remove("amount_basis")
|
|
.unwrap_or_else(|| "billable".into());
|
|
if !matches!(
|
|
amount_basis.as_str(),
|
|
"rated" | "billable" | "quota_covered" | "wallet_consumed" | "wallet_debit"
|
|
) {
|
|
return Err("unsupported amount_basis".into());
|
|
}
|
|
let user_id = text(&mut params, "user_id")?;
|
|
let attribution_kind = text(&mut params, "attribution_kind")?;
|
|
let explicit_owner = text(&mut params, "credential_owner_id")?;
|
|
if user_id.is_some() && explicit_owner.is_some() {
|
|
return Err("user_id and credential_owner_id cannot be combined".into());
|
|
}
|
|
let member_account = attribution_kind.as_deref() == Some("employee");
|
|
let status = match text(&mut params, "status")?.as_deref() {
|
|
None => None,
|
|
Some("success" | "completed") => Some("completed".into()),
|
|
Some(value @ ("failed" | "cancelled" | "pending" | "streaming")) => Some(value.to_string()),
|
|
_ => return Err("unsupported request status".into()),
|
|
};
|
|
let search = text(&mut params, "search")?;
|
|
if search.is_some() && view != UsageAnalyticsView::Users {
|
|
return Err("search is only supported by employee reports".into());
|
|
}
|
|
let slow_threshold_ms = params
|
|
.remove("slow_threshold_ms")
|
|
.map(|value| {
|
|
value
|
|
.parse::<u64>()
|
|
.map_err(|_| "invalid slow_threshold_ms".to_string())
|
|
})
|
|
.transpose()?;
|
|
if slow_threshold_ms.is_some_and(|value| value == 0 || value > 86_400_000) {
|
|
return Err("slow_threshold_ms must be between 1 and 86400000".into());
|
|
}
|
|
let query = UsageAnalyticsQuery {
|
|
from_unix_ms: from,
|
|
to_unix_ms: to,
|
|
timezone,
|
|
view,
|
|
group_by,
|
|
granularity,
|
|
actor_user_id: member_account.then(|| user_id.clone()).flatten(),
|
|
credential_owner_id: if member_account {
|
|
explicit_owner
|
|
} else {
|
|
user_id.or(explicit_owner)
|
|
},
|
|
attribution_kind,
|
|
api_key_id: text(&mut params, "api_key_id")?,
|
|
model: text(&mut params, "model")?,
|
|
provider_id: text(&mut params, "provider_id")?,
|
|
api_format: text(&mut params, "api_format")?,
|
|
endpoint_kind: text(&mut params, "endpoint_kind")?,
|
|
request_type: text(&mut params, "request_type")?,
|
|
status,
|
|
is_stream: boolean(&mut params, "is_stream")?,
|
|
has_format_conversion: boolean(&mut params, "has_format_conversion")?,
|
|
slow_threshold_ms,
|
|
search,
|
|
user_is_active,
|
|
has_usage,
|
|
sort,
|
|
descending,
|
|
limit: if csv {
|
|
OVERVIEW_EXPORT_LIMIT + 1
|
|
} else {
|
|
limit
|
|
},
|
|
offset,
|
|
payment_limit,
|
|
payment_offset,
|
|
};
|
|
if let Some(key) = params.keys().next() {
|
|
return Err(format!("unsupported query parameter: {key}"));
|
|
}
|
|
query.validate().map_err(|err| err.to_string())?;
|
|
Ok(OverviewRequest {
|
|
query,
|
|
csv,
|
|
amount_basis,
|
|
})
|
|
}
|
|
|
|
fn timestamp(params: &mut BTreeMap<String, String>, key: &str) -> Result<u64, String> {
|
|
let value = params
|
|
.remove(key)
|
|
.ok_or_else(|| format!("{key} is required"))?;
|
|
let value = DateTime::parse_from_rfc3339(&value)
|
|
.map_err(|_| format!("{key} must be an RFC 3339 timestamp"))?
|
|
.with_timezone(&Utc);
|
|
u64::try_from(value.timestamp_millis())
|
|
.map_err(|_| format!("{key} must not precede the Unix epoch"))
|
|
}
|
|
|
|
fn number<T: std::str::FromStr>(
|
|
params: &mut BTreeMap<String, String>,
|
|
key: &str,
|
|
default: T,
|
|
) -> Result<T, String> {
|
|
params
|
|
.remove(key)
|
|
.map(|value| value.parse().map_err(|_| format!("invalid {key}")))
|
|
.unwrap_or(Ok(default))
|
|
}
|
|
|
|
fn boolean(params: &mut BTreeMap<String, String>, key: &str) -> Result<Option<bool>, String> {
|
|
match params.remove(key).as_deref() {
|
|
None => Ok(None),
|
|
Some("true") => Ok(Some(true)),
|
|
Some("false") => Ok(Some(false)),
|
|
_ => Err(format!("{key} must be true or false")),
|
|
}
|
|
}
|
|
|
|
fn text(params: &mut BTreeMap<String, String>, key: &str) -> Result<Option<String>, String> {
|
|
params
|
|
.remove(key)
|
|
.map(|value| {
|
|
let value = value.trim();
|
|
if value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) {
|
|
Err(format!("invalid {key}"))
|
|
} else {
|
|
Ok(value.to_string())
|
|
}
|
|
})
|
|
.transpose()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const RANGE: &str =
|
|
"from=2026-09-01T23:45:00Z&to=2026-09-02T00:15:00Z&timezone=Asia%2FShanghai";
|
|
|
|
#[test]
|
|
fn preserves_precise_cross_midnight_bounds() {
|
|
let parsed = parse_overview_query(Some(RANGE), UsageAnalyticsView::Summary).unwrap();
|
|
assert_eq!(
|
|
parsed.query.to_unix_ms - parsed.query.from_unix_ms,
|
|
30 * 60 * 1000
|
|
);
|
|
assert_eq!(parsed.query.timezone, "Asia/Shanghai");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unknown_duplicate_and_invalid_ranges() {
|
|
for suffix in [
|
|
"&model=x&model=y",
|
|
"&invented=1",
|
|
"&limit=101",
|
|
"&timezone=Europe/Paris",
|
|
"&is_stream=perhaps",
|
|
] {
|
|
assert!(parse_overview_query(
|
|
Some(&format!("{RANGE}{suffix}")),
|
|
UsageAnalyticsView::Summary
|
|
)
|
|
.is_err());
|
|
}
|
|
assert!(parse_overview_query(
|
|
Some("from=2026-09-01T00:00:00Z&to=2026-09-01T00:00:00Z"),
|
|
UsageAnalyticsView::Summary
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn supports_iana_zone_across_dst_and_full_filter_exports() {
|
|
let parsed = parse_overview_query(Some("from=2026-03-08T05:00:00Z&to=2026-03-09T04:00:00Z&timezone=America%2FNew_York&format=csv&search=alice"), UsageAnalyticsView::Users).unwrap();
|
|
assert_eq!(
|
|
parsed.query.to_unix_ms - parsed.query.from_unix_ms,
|
|
23 * 3_600_000
|
|
);
|
|
assert_eq!(parsed.query.limit, OVERVIEW_EXPORT_LIMIT + 1);
|
|
assert_eq!(parsed.query.search.as_deref(), Some("alice"));
|
|
}
|
|
|
|
#[test]
|
|
fn user_payments_have_independent_bounded_pagination() {
|
|
let raw = format!("{RANGE}&payment_limit=10&payment_offset=20&limit=1&offset=0");
|
|
let parsed = parse_overview_query(Some(&raw), UsageAnalyticsView::Users).unwrap();
|
|
assert_eq!(parsed.query.payment_limit, Some(10));
|
|
assert_eq!(parsed.query.payment_offset, Some(20));
|
|
assert_eq!(parsed.query.limit, 1);
|
|
assert_eq!(parsed.query.offset, 0);
|
|
assert!(parse_overview_query(Some(&raw), UsageAnalyticsView::Summary).is_err());
|
|
for value in ["0", "101", "-1", "garbage"] {
|
|
assert!(parse_overview_query(
|
|
Some(&format!("{RANGE}&payment_limit={value}")),
|
|
UsageAnalyticsView::Users
|
|
)
|
|
.is_err());
|
|
}
|
|
}
|
|
}
|