Add multi-database data layer

Introduce aether-data-schema and driver-specific schema generation for Postgres, MySQL, and SQLite.

Split data backends, lifecycle, repositories, and gateway runtime integration across database drivers.

Verified with cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, and cargo test --workspace.
This commit is contained in:
fawney19
2026-05-05 18:27:36 +08:00
parent 099653f732
commit fce7e959e5
372 changed files with 86217 additions and 21160 deletions
@@ -0,0 +1,371 @@
use chrono::{DateTime, Utc};
use sqlx::Row;
use crate::backend::stats_common::{stats_id, unix_ms, unix_secs, utc_from_unix_secs};
use crate::backend::MysqlBackend;
use crate::driver::mysql::MysqlPool;
use crate::error::SqlResultExt;
use crate::{
DataLayerError, StatsDailyAggregationInput, StatsDailyAggregationSummary,
StatsHourlyAggregationInput, StatsHourlyAggregationSummary,
};
impl MysqlBackend {
pub async fn aggregate_stats_hourly(
&self,
input: &StatsHourlyAggregationInput,
) -> Result<Option<StatsHourlyAggregationSummary>, DataLayerError> {
let Some(hour_utc_unix_secs) =
next_mysql_stats_hourly_bucket(self.pool(), input.target_hour_utc).await?
else {
return Ok(None);
};
perform_mysql_stats_hourly_aggregation(self.pool(), hour_utc_unix_secs, input.aggregated_at)
.await
.map(Some)
}
pub async fn aggregate_stats_daily(
&self,
input: &StatsDailyAggregationInput,
) -> Result<Option<StatsDailyAggregationSummary>, DataLayerError> {
let Some(day_start_unix_secs) =
next_mysql_stats_daily_bucket(self.pool(), input.target_day_utc).await?
else {
return Ok(None);
};
perform_mysql_stats_daily_aggregation(self.pool(), day_start_unix_secs, input.aggregated_at)
.await
.map(Some)
}
}
async fn next_mysql_stats_hourly_bucket(
pool: &MysqlPool,
target_hour_utc: DateTime<Utc>,
) -> Result<Option<i64>, DataLayerError> {
let latest_hour: Option<i64> =
sqlx::query_scalar("SELECT MAX(hour_utc) FROM stats_hourly WHERE is_complete <> 0")
.fetch_one(pool)
.await
.map_sql_err()?;
let search_from = latest_hour.map(|value| value + 3600).unwrap_or(0);
let search_until = unix_secs(target_hour_utc) + 3600;
if search_from >= search_until {
return Ok(None);
}
let next_bucket: Option<i64> = sqlx::query_scalar(
r#"
SELECT MIN(FLOOR(created_at_unix_ms / 3600000) * 3600)
FROM `usage`
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#,
)
.bind(unix_ms(search_from)?)
.bind(unix_ms(search_until)?)
.fetch_one(pool)
.await
.map_sql_err()?;
Ok(next_bucket.filter(|value| *value <= unix_secs(target_hour_utc)))
}
async fn next_mysql_stats_daily_bucket(
pool: &MysqlPool,
target_day_utc: DateTime<Utc>,
) -> Result<Option<i64>, DataLayerError> {
let latest_day: Option<i64> =
sqlx::query_scalar("SELECT MAX(`date`) FROM stats_daily WHERE is_complete <> 0")
.fetch_one(pool)
.await
.map_sql_err()?;
let search_from = latest_day.map(|value| value + 86_400).unwrap_or(0);
let search_until = unix_secs(target_day_utc) + 86_400;
if search_from >= search_until {
return Ok(None);
}
let next_bucket: Option<i64> = sqlx::query_scalar(
r#"
SELECT MIN(FLOOR(created_at_unix_ms / 86400000) * 86400)
FROM `usage`
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#,
)
.bind(unix_ms(search_from)?)
.bind(unix_ms(search_until)?)
.fetch_one(pool)
.await
.map_sql_err()?;
Ok(next_bucket.filter(|value| *value <= unix_secs(target_day_utc)))
}
const MYSQL_STATS_AGGREGATE_SQL: &str = r#"
SELECT
COUNT(*) AS total_requests,
COALESCE(SUM(CASE
WHEN status = 'failed'
OR status_code >= 400
OR (error_category IS NOT NULL AND error_category <> '')
THEN 1 ELSE 0 END), 0) AS error_requests,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_creation_input_tokens), 0) AS cache_creation_tokens,
COALESCE(SUM(cache_read_input_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(total_cost_usd), 0.0) AS total_cost,
COALESCE(SUM(actual_total_cost_usd), 0.0) AS actual_total_cost,
COALESCE(AVG(response_time_ms), 0.0) AS avg_response_time_ms
FROM `usage`
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#;
async fn perform_mysql_stats_hourly_aggregation(
pool: &MysqlPool,
hour_utc_unix_secs: i64,
aggregated_at: DateTime<Utc>,
) -> Result<StatsHourlyAggregationSummary, DataLayerError> {
let start_ms = unix_ms(hour_utc_unix_secs)?;
let end_ms = unix_ms(hour_utc_unix_secs + 3600)?;
let aggregated_at_unix_secs = unix_secs(aggregated_at);
let mut tx = pool.begin().await.map_sql_err()?;
let row = sqlx::query(MYSQL_STATS_AGGREGATE_SQL)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut *tx)
.await
.map_sql_err()?;
let total_requests: i64 = row.try_get("total_requests").map_sql_err()?;
let error_requests: i64 = row.try_get("error_requests").map_sql_err()?;
sqlx::query(
r#"
INSERT INTO stats_hourly (
id, hour_utc, total_requests, success_requests, error_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
total_cost, actual_total_cost, avg_response_time_ms, is_complete,
aggregated_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, TRUE, ?, ?, ?)
ON DUPLICATE KEY UPDATE
total_requests = VALUES(total_requests),
success_requests = VALUES(success_requests),
error_requests = VALUES(error_requests),
input_tokens = VALUES(input_tokens),
output_tokens = VALUES(output_tokens),
cache_creation_tokens = VALUES(cache_creation_tokens),
cache_read_tokens = VALUES(cache_read_tokens),
total_cost = VALUES(total_cost),
actual_total_cost = VALUES(actual_total_cost),
avg_response_time_ms = VALUES(avg_response_time_ms),
is_complete = VALUES(is_complete),
aggregated_at = VALUES(aggregated_at),
updated_at = VALUES(updated_at)
"#,
)
.bind(stats_id(&format!("stats-hourly:{hour_utc_unix_secs}")))
.bind(hour_utc_unix_secs)
.bind(total_requests)
.bind(total_requests.saturating_sub(error_requests))
.bind(error_requests)
.bind(row.try_get::<i64, _>("input_tokens").map_sql_err()?)
.bind(row.try_get::<i64, _>("output_tokens").map_sql_err()?)
.bind(
row.try_get::<i64, _>("cache_creation_tokens")
.map_sql_err()?,
)
.bind(row.try_get::<i64, _>("cache_read_tokens").map_sql_err()?)
.bind(row.try_get::<f64, _>("total_cost").map_sql_err()?)
.bind(row.try_get::<f64, _>("actual_total_cost").map_sql_err()?)
.bind(
row.try_get::<f64, _>("avg_response_time_ms")
.map_sql_err()?,
)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.execute(&mut *tx)
.await
.map_sql_err()?;
let user_rows = mysql_group_count(&mut tx, "user_id", start_ms, end_ms).await?;
let user_model_rows = mysql_group_count(&mut tx, "user_id, model", start_ms, end_ms).await?;
let model_rows = mysql_group_count(&mut tx, "model", start_ms, end_ms).await?;
let provider_rows = mysql_group_count(&mut tx, "provider_name", start_ms, end_ms).await?;
tx.commit().await.map_sql_err()?;
Ok(StatsHourlyAggregationSummary {
hour_utc: utc_from_unix_secs(hour_utc_unix_secs, "stats_hourly.hour_utc")?,
total_requests,
user_rows,
user_model_rows,
model_rows,
provider_rows,
})
}
async fn perform_mysql_stats_daily_aggregation(
pool: &MysqlPool,
day_start_unix_secs: i64,
aggregated_at: DateTime<Utc>,
) -> Result<StatsDailyAggregationSummary, DataLayerError> {
let start_ms = unix_ms(day_start_unix_secs)?;
let end_ms = unix_ms(day_start_unix_secs + 86_400)?;
let aggregated_at_unix_secs = unix_secs(aggregated_at);
let mut tx = pool.begin().await.map_sql_err()?;
let row = sqlx::query(MYSQL_STATS_AGGREGATE_SQL)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut *tx)
.await
.map_sql_err()?;
let total_requests: i64 = row.try_get("total_requests").map_sql_err()?;
let error_requests: i64 = row.try_get("error_requests").map_sql_err()?;
let unique_models = mysql_group_count(&mut tx, "model", start_ms, end_ms).await? as i64;
let unique_providers =
mysql_group_count(&mut tx, "provider_name", start_ms, end_ms).await? as i64;
sqlx::query(
r#"
INSERT INTO stats_daily (
id, `date`, total_requests, success_requests, error_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
total_cost, actual_total_cost, avg_response_time_ms, fallback_count,
unique_models, unique_providers, is_complete, aggregated_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, TRUE, ?, ?, ?)
ON DUPLICATE KEY UPDATE
total_requests = VALUES(total_requests),
success_requests = VALUES(success_requests),
error_requests = VALUES(error_requests),
input_tokens = VALUES(input_tokens),
output_tokens = VALUES(output_tokens),
cache_creation_tokens = VALUES(cache_creation_tokens),
cache_read_tokens = VALUES(cache_read_tokens),
total_cost = VALUES(total_cost),
actual_total_cost = VALUES(actual_total_cost),
avg_response_time_ms = VALUES(avg_response_time_ms),
fallback_count = VALUES(fallback_count),
unique_models = VALUES(unique_models),
unique_providers = VALUES(unique_providers),
is_complete = VALUES(is_complete),
aggregated_at = VALUES(aggregated_at),
updated_at = VALUES(updated_at)
"#,
)
.bind(stats_id(&format!("stats-daily:{day_start_unix_secs}")))
.bind(day_start_unix_secs)
.bind(total_requests)
.bind(total_requests.saturating_sub(error_requests))
.bind(error_requests)
.bind(row.try_get::<i64, _>("input_tokens").map_sql_err()?)
.bind(row.try_get::<i64, _>("output_tokens").map_sql_err()?)
.bind(
row.try_get::<i64, _>("cache_creation_tokens")
.map_sql_err()?,
)
.bind(row.try_get::<i64, _>("cache_read_tokens").map_sql_err()?)
.bind(row.try_get::<f64, _>("total_cost").map_sql_err()?)
.bind(row.try_get::<f64, _>("actual_total_cost").map_sql_err()?)
.bind(
row.try_get::<f64, _>("avg_response_time_ms")
.map_sql_err()?,
)
.bind(unique_models)
.bind(unique_providers)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.execute(&mut *tx)
.await
.map_sql_err()?;
let model_rows = usize::try_from(unique_models).unwrap_or(usize::MAX);
let provider_rows = usize::try_from(unique_providers).unwrap_or(usize::MAX);
let api_key_rows = mysql_group_count(&mut tx, "api_key_id", start_ms, end_ms).await?;
let error_rows = mysql_error_group_count(&mut tx, start_ms, end_ms).await?;
let user_rows = mysql_group_count(&mut tx, "user_id", start_ms, end_ms).await?;
tx.commit().await.map_sql_err()?;
Ok(StatsDailyAggregationSummary {
day_start_utc: utc_from_unix_secs(day_start_unix_secs, "stats_daily.date")?,
total_requests,
model_rows,
provider_rows,
api_key_rows,
error_rows,
user_rows,
})
}
async fn mysql_group_count(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
group_columns: &str,
start_ms: i64,
end_ms: i64,
) -> Result<usize, DataLayerError> {
let not_empty = group_columns
.split(',')
.map(str::trim)
.map(|column| format!("{column} IS NOT NULL AND {column} <> ''"))
.collect::<Vec<_>>()
.join(" AND ");
let sql = format!(
r#"
SELECT COUNT(*)
FROM (
SELECT 1
FROM `usage`
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
AND {not_empty}
GROUP BY {group_columns}
) AS grouped
"#
);
let count: i64 = sqlx::query_scalar(&sql)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut **tx)
.await
.map_sql_err()?;
Ok(usize::try_from(count.max(0)).unwrap_or(usize::MAX))
}
async fn mysql_error_group_count(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
start_ms: i64,
end_ms: i64,
) -> Result<usize, DataLayerError> {
let count: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM (
SELECT 1
FROM `usage`
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
AND (
status = 'failed'
OR status_code >= 400
OR (error_category IS NOT NULL AND error_category <> '')
)
GROUP BY COALESCE(NULLIF(error_category, ''), 'unknown_error'), provider_name, model
) AS grouped
"#,
)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut **tx)
.await
.map_sql_err()?;
Ok(usize::try_from(count.max(0)).unwrap_or(usize::MAX))
}
@@ -0,0 +1,638 @@
use chrono::{DateTime, Utc};
use sqlx::Row;
use uuid::Uuid;
use crate::backend::PostgresBackend;
use crate::{
error::postgres_error, DataLayerError, StatsDailyAggregationInput, StatsDailyAggregationSummary,
};
mod percentiles;
mod sql;
use self::percentiles::{percentile_ms_to_i64, PercentileSummary};
use self::sql::*;
impl PostgresBackend {
pub async fn aggregate_stats_daily(
&self,
input: &StatsDailyAggregationInput,
) -> Result<Option<StatsDailyAggregationSummary>, DataLayerError> {
let Some(day_start_utc) = next_stats_aggregation_day(self.pool(), input.target_day_utc)
.await
.map_err(postgres_error)?
else {
return Ok(None);
};
perform_stats_aggregation_for_day(self.pool(), day_start_utc, input.aggregated_at)
.await
.map(Some)
.map_err(postgres_error)
}
}
async fn next_stats_aggregation_day(
pool: &crate::driver::postgres::PostgresPool,
target_day_utc: DateTime<Utc>,
) -> Result<Option<DateTime<Utc>>, sqlx::Error> {
let latest_row = sqlx::query(SELECT_LATEST_STATS_DAILY_DATE_SQL)
.fetch_one(pool)
.await?;
let latest_day = latest_row.try_get::<Option<DateTime<Utc>>, _>("latest_date")?;
let search_from = latest_day
.map(|value| value + chrono::Duration::days(1))
.unwrap_or_else(|| {
DateTime::<Utc>::from_timestamp(0, 0).expect("unix epoch should be valid")
});
let search_until = target_day_utc + chrono::Duration::days(1);
if search_from >= search_until {
return Ok(None);
}
let next_row = sqlx::query(SELECT_NEXT_STATS_DAILY_BUCKET_SQL)
.bind(search_from)
.bind(search_until)
.fetch_one(pool)
.await?;
let next_bucket = next_row.try_get::<Option<DateTime<Utc>>, _>("next_bucket")?;
Ok(next_bucket.filter(|value| *value <= target_day_utc))
}
async fn perform_stats_aggregation_for_day(
pool: &crate::driver::postgres::PostgresPool,
day_start_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<StatsDailyAggregationSummary, sqlx::Error> {
let day_end_utc = day_start_utc + chrono::Duration::days(1);
let mut tx = pool.begin().await?;
let aggregate_row = sqlx::query(SELECT_STATS_DAILY_AGGREGATE_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.fetch_one(&mut *tx)
.await?;
let total_requests = aggregate_row.try_get::<i64, _>("total_requests")?;
let error_requests = aggregate_row.try_get::<i64, _>("error_requests")?;
let success_requests = total_requests.saturating_sub(error_requests);
let fallback_count = sqlx::query(SELECT_STATS_DAILY_FALLBACK_COUNT_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(vec!["success", "failed"])
.fetch_one(&mut *tx)
.await?
.try_get::<i64, _>("fallback_count")?;
let response_percentiles = fetch_stats_daily_percentiles(
&mut tx,
SELECT_STATS_DAILY_RESPONSE_TIME_PERCENTILES_SQL,
day_start_utc,
day_end_utc,
)
.await?;
let first_byte_percentiles = fetch_stats_daily_percentiles(
&mut tx,
SELECT_STATS_DAILY_FIRST_BYTE_PERCENTILES_SQL,
day_start_utc,
day_end_utc,
)
.await?;
sqlx::query(UPSERT_STATS_DAILY_SQL)
.bind(Uuid::new_v4().to_string())
.bind(day_start_utc)
.bind(total_requests)
.bind(aggregate_row.try_get::<i64, _>("cache_hit_total_requests")?)
.bind(aggregate_row.try_get::<i64, _>("cache_hit_requests")?)
.bind(aggregate_row.try_get::<i64, _>("completed_total_requests")?)
.bind(aggregate_row.try_get::<i64, _>("completed_cache_hit_requests")?)
.bind(aggregate_row.try_get::<i64, _>("completed_input_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("completed_cache_creation_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("completed_cache_read_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("completed_total_input_context")?)
.bind(aggregate_row.try_get::<f64, _>("completed_cache_creation_cost")?)
.bind(aggregate_row.try_get::<f64, _>("completed_cache_read_cost")?)
.bind(aggregate_row.try_get::<f64, _>("settled_total_cost")?)
.bind(aggregate_row.try_get::<i64, _>("settled_total_requests")?)
.bind(aggregate_row.try_get::<i64, _>("settled_input_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("settled_output_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("settled_cache_creation_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("settled_cache_read_tokens")?)
.bind(aggregate_row.try_get::<Option<i64>, _>("settled_first_finalized_at_unix_secs")?)
.bind(aggregate_row.try_get::<Option<i64>, _>("settled_last_finalized_at_unix_secs")?)
.bind(success_requests)
.bind(error_requests)
.bind(aggregate_row.try_get::<i64, _>("input_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("effective_input_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("output_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("cache_creation_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("cache_creation_ephemeral_5m_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("cache_creation_ephemeral_1h_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("cache_read_tokens")?)
.bind(aggregate_row.try_get::<i64, _>("total_input_context")?)
.bind(aggregate_row.try_get::<f64, _>("total_cost")?)
.bind(aggregate_row.try_get::<f64, _>("actual_total_cost")?)
.bind(aggregate_row.try_get::<f64, _>("input_cost")?)
.bind(aggregate_row.try_get::<f64, _>("output_cost")?)
.bind(aggregate_row.try_get::<f64, _>("cache_creation_cost")?)
.bind(aggregate_row.try_get::<f64, _>("cache_read_cost")?)
.bind(aggregate_row.try_get::<f64, _>("response_time_sum_ms")?)
.bind(aggregate_row.try_get::<i64, _>("response_time_samples")?)
.bind(aggregate_row.try_get::<f64, _>("avg_response_time_ms")?)
.bind(response_percentiles.p50)
.bind(response_percentiles.p90)
.bind(response_percentiles.p99)
.bind(first_byte_percentiles.p50)
.bind(first_byte_percentiles.p90)
.bind(first_byte_percentiles.p99)
.bind(fallback_count)
.bind(aggregate_row.try_get::<i64, _>("unique_models")?)
.bind(aggregate_row.try_get::<i64, _>("unique_providers")?)
.bind(true)
.bind(now_utc)
.bind(now_utc)
.bind(now_utc)
.execute(&mut *tx)
.await?;
let model_rows =
upsert_stats_daily_model_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
let provider_rows =
upsert_stats_daily_provider_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
upsert_stats_daily_model_provider_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
upsert_stats_daily_cost_savings_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
upsert_stats_daily_cost_savings_provider_rows(&mut tx, day_start_utc, day_end_utc, now_utc)
.await?;
upsert_stats_daily_cost_savings_model_rows(&mut tx, day_start_utc, day_end_utc, now_utc)
.await?;
upsert_stats_daily_cost_savings_model_provider_rows(
&mut tx,
day_start_utc,
day_end_utc,
now_utc,
)
.await?;
let api_key_rows =
upsert_stats_daily_api_key_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
let error_rows =
refresh_stats_daily_error_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
let user_rows =
upsert_stats_user_daily_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
upsert_stats_user_daily_model_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
upsert_stats_user_daily_model_provider_rows(&mut tx, day_start_utc, day_end_utc, now_utc)
.await?;
upsert_stats_user_daily_provider_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
upsert_stats_user_daily_cost_savings_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
upsert_stats_user_daily_cost_savings_provider_rows(
&mut tx,
day_start_utc,
day_end_utc,
now_utc,
)
.await?;
upsert_stats_user_daily_cost_savings_model_rows(&mut tx, day_start_utc, day_end_utc, now_utc)
.await?;
upsert_stats_user_daily_cost_savings_model_provider_rows(
&mut tx,
day_start_utc,
day_end_utc,
now_utc,
)
.await?;
upsert_stats_user_daily_api_format_rows(&mut tx, day_start_utc, day_end_utc, now_utc).await?;
refresh_stats_summary_row(&mut tx, day_end_utc, now_utc).await?;
refresh_stats_user_summary_rows(&mut tx, day_end_utc, now_utc).await?;
tx.commit().await?;
Ok(StatsDailyAggregationSummary {
day_start_utc,
total_requests,
model_rows,
provider_rows,
api_key_rows,
error_rows,
user_rows,
})
}
async fn fetch_stats_daily_percentiles(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
sql: &str,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
) -> Result<PercentileSummary, sqlx::Error> {
let row = sqlx::query(sql)
.bind(day_start_utc)
.bind(day_end_utc)
.fetch_one(&mut **tx)
.await?;
let sample_count = row.try_get::<i64, _>("sample_count")?;
if sample_count < 10 {
return Ok(PercentileSummary::default());
}
Ok(PercentileSummary {
p50: percentile_ms_to_i64(row.try_get::<Option<f64>, _>("p50")?),
p90: percentile_ms_to_i64(row.try_get::<Option<f64>, _>("p90")?),
p99: percentile_ms_to_i64(row.try_get::<Option<f64>, _>("p99")?),
})
}
async fn upsert_stats_daily_model_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_MODEL_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_daily_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_daily_model_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_MODEL_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_daily_cost_savings_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_COST_SAVINGS_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_daily_cost_savings_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_COST_SAVINGS_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_daily_cost_savings_model_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_COST_SAVINGS_MODEL_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_daily_cost_savings_model_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_COST_SAVINGS_MODEL_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_daily_api_key_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_DAILY_API_KEY_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn refresh_stats_daily_error_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
sqlx::query(DELETE_STATS_DAILY_ERRORS_FOR_DATE_SQL)
.bind(day_start_utc)
.execute(&mut **tx)
.await?;
let rows_affected = sqlx::query(INSERT_STATS_DAILY_ERROR_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_model_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_MODEL_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_model_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_MODEL_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_cost_savings_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_COST_SAVINGS_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_cost_savings_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_COST_SAVINGS_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_cost_savings_model_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_COST_SAVINGS_MODEL_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_cost_savings_model_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_COST_SAVINGS_MODEL_PROVIDER_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_user_daily_api_format_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
day_start_utc: DateTime<Utc>,
day_end_utc: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_USER_DAILY_API_FORMAT_SQL)
.bind(day_start_utc)
.bind(day_end_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn refresh_stats_summary_row(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
cutoff_date: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<(), sqlx::Error> {
let totals_row = sqlx::query(SELECT_STATS_SUMMARY_TOTALS_SQL)
.bind(cutoff_date)
.fetch_one(&mut **tx)
.await?;
let entity_counts_row = sqlx::query(SELECT_STATS_SUMMARY_ENTITY_COUNTS_SQL)
.fetch_one(&mut **tx)
.await?;
let existing_summary_id = sqlx::query_scalar::<_, String>(SELECT_EXISTING_STATS_SUMMARY_ID_SQL)
.fetch_optional(&mut **tx)
.await?;
let all_time_requests = totals_row.try_get::<i64, _>("all_time_requests")?;
let all_time_success_requests = totals_row.try_get::<i64, _>("all_time_success_requests")?;
let all_time_error_requests = totals_row.try_get::<i64, _>("all_time_error_requests")?;
let all_time_input_tokens = totals_row.try_get::<i64, _>("all_time_input_tokens")?;
let all_time_output_tokens = totals_row.try_get::<i64, _>("all_time_output_tokens")?;
let all_time_cache_creation_tokens =
totals_row.try_get::<i64, _>("all_time_cache_creation_tokens")?;
let all_time_cache_read_tokens = totals_row.try_get::<i64, _>("all_time_cache_read_tokens")?;
let all_time_cost = totals_row.try_get::<f64, _>("all_time_cost")?;
let all_time_actual_cost = totals_row.try_get::<f64, _>("all_time_actual_cost")?;
let total_users = entity_counts_row.try_get::<i64, _>("total_users")?;
let active_users = entity_counts_row.try_get::<i64, _>("active_users")?;
let total_api_keys = entity_counts_row.try_get::<i64, _>("total_api_keys")?;
let active_api_keys = entity_counts_row.try_get::<i64, _>("active_api_keys")?;
if let Some(summary_id) = existing_summary_id {
sqlx::query(UPDATE_STATS_SUMMARY_SQL)
.bind(summary_id)
.bind(cutoff_date)
.bind(all_time_requests)
.bind(all_time_success_requests)
.bind(all_time_error_requests)
.bind(all_time_input_tokens)
.bind(all_time_output_tokens)
.bind(all_time_cache_creation_tokens)
.bind(all_time_cache_read_tokens)
.bind(all_time_cost)
.bind(all_time_actual_cost)
.bind(total_users)
.bind(active_users)
.bind(total_api_keys)
.bind(active_api_keys)
.bind(now_utc)
.execute(&mut **tx)
.await?;
} else {
sqlx::query(INSERT_STATS_SUMMARY_SQL)
.bind(Uuid::new_v4().to_string())
.bind(cutoff_date)
.bind(all_time_requests)
.bind(all_time_success_requests)
.bind(all_time_error_requests)
.bind(all_time_input_tokens)
.bind(all_time_output_tokens)
.bind(all_time_cache_creation_tokens)
.bind(all_time_cache_read_tokens)
.bind(all_time_cost)
.bind(all_time_actual_cost)
.bind(total_users)
.bind(active_users)
.bind(total_api_keys)
.bind(active_api_keys)
.bind(now_utc)
.bind(now_utc)
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn refresh_stats_user_summary_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
cutoff_date: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<(), sqlx::Error> {
sqlx::query(UPSERT_STATS_USER_SUMMARY_SQL)
.bind(cutoff_date)
.bind(now_utc)
.execute(&mut **tx)
.await?;
Ok(())
}
@@ -0,0 +1,10 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(super) struct PercentileSummary {
pub(super) p50: Option<i64>,
pub(super) p90: Option<i64>,
pub(super) p99: Option<i64>,
}
pub(super) fn percentile_ms_to_i64(value: Option<f64>) -> Option<i64> {
value.and_then(|raw| raw.is_finite().then_some(raw.floor() as i64))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,203 @@
use chrono::{DateTime, Utc};
use sqlx::Row;
use uuid::Uuid;
use crate::backend::PostgresBackend;
use crate::{
error::postgres_error, DataLayerError, StatsHourlyAggregationInput,
StatsHourlyAggregationSummary,
};
mod sql;
use self::sql::*;
impl PostgresBackend {
pub async fn aggregate_stats_hourly(
&self,
input: &StatsHourlyAggregationInput,
) -> Result<Option<StatsHourlyAggregationSummary>, DataLayerError> {
let Some(hour_utc) = next_stats_hourly_bucket(self.pool(), input.target_hour_utc)
.await
.map_err(postgres_error)?
else {
return Ok(None);
};
perform_stats_hourly_aggregation_for_hour(self.pool(), hour_utc, input.aggregated_at)
.await
.map(Some)
.map_err(postgres_error)
}
}
async fn next_stats_hourly_bucket(
pool: &crate::driver::postgres::PostgresPool,
target_hour_utc: DateTime<Utc>,
) -> Result<Option<DateTime<Utc>>, sqlx::Error> {
let latest_row = sqlx::query(SELECT_LATEST_STATS_HOURLY_HOUR_SQL)
.fetch_one(pool)
.await?;
let latest_hour = latest_row.try_get::<Option<DateTime<Utc>>, _>("latest_hour")?;
let search_from = latest_hour
.map(|value| value + chrono::Duration::hours(1))
.unwrap_or_else(|| {
DateTime::<Utc>::from_timestamp(0, 0).expect("unix epoch should be valid")
});
let search_until = target_hour_utc + chrono::Duration::hours(1);
if search_from >= search_until {
return Ok(None);
}
let next_row = sqlx::query(SELECT_NEXT_STATS_HOURLY_BUCKET_SQL)
.bind(search_from)
.bind(search_until)
.fetch_one(pool)
.await?;
let next_bucket = next_row.try_get::<Option<DateTime<Utc>>, _>("next_bucket")?;
Ok(next_bucket.filter(|value| *value <= target_hour_utc))
}
async fn perform_stats_hourly_aggregation_for_hour(
pool: &crate::driver::postgres::PostgresPool,
hour_utc: DateTime<Utc>,
aggregated_at: DateTime<Utc>,
) -> Result<StatsHourlyAggregationSummary, sqlx::Error> {
let hour_end = hour_utc + chrono::Duration::hours(1);
let mut tx = pool.begin().await?;
let row = sqlx::query(SELECT_STATS_HOURLY_AGGREGATE_SQL)
.bind(hour_utc)
.bind(hour_end)
.fetch_one(&mut *tx)
.await?;
let total_requests = row.try_get::<i64, _>("total_requests")?;
let error_requests = row.try_get::<i64, _>("error_requests")?;
let success_requests = total_requests.saturating_sub(error_requests);
sqlx::query(UPSERT_STATS_HOURLY_SQL)
.bind(Uuid::new_v4().to_string())
.bind(hour_utc)
.bind(total_requests)
.bind(row.try_get::<i64, _>("cache_hit_total_requests")?)
.bind(row.try_get::<i64, _>("cache_hit_requests")?)
.bind(row.try_get::<i64, _>("completed_total_requests")?)
.bind(row.try_get::<i64, _>("completed_cache_hit_requests")?)
.bind(row.try_get::<i64, _>("completed_input_tokens")?)
.bind(row.try_get::<i64, _>("completed_cache_creation_tokens")?)
.bind(row.try_get::<i64, _>("completed_cache_read_tokens")?)
.bind(row.try_get::<i64, _>("completed_total_input_context")?)
.bind(row.try_get::<f64, _>("completed_cache_creation_cost")?)
.bind(row.try_get::<f64, _>("completed_cache_read_cost")?)
.bind(row.try_get::<f64, _>("settled_total_cost")?)
.bind(row.try_get::<i64, _>("settled_total_requests")?)
.bind(row.try_get::<i64, _>("settled_input_tokens")?)
.bind(row.try_get::<i64, _>("settled_output_tokens")?)
.bind(row.try_get::<i64, _>("settled_cache_creation_tokens")?)
.bind(row.try_get::<i64, _>("settled_cache_read_tokens")?)
.bind(row.try_get::<Option<i64>, _>("settled_first_finalized_at_unix_secs")?)
.bind(row.try_get::<Option<i64>, _>("settled_last_finalized_at_unix_secs")?)
.bind(success_requests)
.bind(error_requests)
.bind(row.try_get::<i64, _>("input_tokens")?)
.bind(row.try_get::<i64, _>("output_tokens")?)
.bind(row.try_get::<i64, _>("cache_creation_tokens")?)
.bind(row.try_get::<i64, _>("cache_read_tokens")?)
.bind(row.try_get::<f64, _>("total_cost")?)
.bind(row.try_get::<f64, _>("actual_total_cost")?)
.bind(row.try_get::<f64, _>("response_time_sum_ms")?)
.bind(row.try_get::<i64, _>("response_time_samples")?)
.bind(row.try_get::<f64, _>("avg_response_time_ms")?)
.bind(true)
.bind(aggregated_at)
.bind(aggregated_at)
.bind(aggregated_at)
.execute(&mut *tx)
.await?;
let user_rows =
upsert_stats_hourly_user_rows(&mut tx, hour_utc, hour_end, aggregated_at).await?;
let user_model_rows =
upsert_stats_hourly_user_model_rows(&mut tx, hour_utc, hour_end, aggregated_at).await?;
let model_rows =
upsert_stats_hourly_model_rows(&mut tx, hour_utc, hour_end, aggregated_at).await?;
let provider_rows =
upsert_stats_hourly_provider_rows(&mut tx, hour_utc, hour_end, aggregated_at).await?;
tx.commit().await?;
Ok(StatsHourlyAggregationSummary {
hour_utc,
total_requests,
user_rows,
user_model_rows,
model_rows,
provider_rows,
})
}
async fn upsert_stats_hourly_user_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
hour_utc: DateTime<Utc>,
hour_end: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_HOURLY_USER_SQL)
.bind(hour_utc)
.bind(hour_end)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_hourly_model_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
hour_utc: DateTime<Utc>,
hour_end: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_HOURLY_MODEL_SQL)
.bind(hour_utc)
.bind(hour_end)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_hourly_user_model_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
hour_utc: DateTime<Utc>,
hour_end: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_HOURLY_USER_MODEL_SQL)
.bind(hour_utc)
.bind(hour_end)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
async fn upsert_stats_hourly_provider_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
hour_utc: DateTime<Utc>,
hour_end: DateTime<Utc>,
now_utc: DateTime<Utc>,
) -> Result<usize, sqlx::Error> {
let rows_affected = sqlx::query(UPSERT_STATS_HOURLY_PROVIDER_SQL)
.bind(hour_utc)
.bind(hour_end)
.bind(now_utc)
.execute(&mut **tx)
.await?
.rows_affected();
Ok(usize::try_from(rows_affected).unwrap_or(usize::MAX))
}
@@ -0,0 +1,906 @@
pub(super) const SELECT_LATEST_STATS_HOURLY_HOUR_SQL: &str = r#"
SELECT MAX(hour_utc) AS latest_hour
FROM stats_hourly
WHERE is_complete IS TRUE
"#;
pub(super) const SELECT_NEXT_STATS_HOURLY_BUCKET_SQL: &str = r#"
SELECT date_trunc('hour', MIN(created_at)) AS next_bucket
FROM usage_billing_facts AS usage
WHERE created_at >= $1
AND created_at < $2
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#;
pub(super) const SELECT_STATS_HOURLY_AGGREGATE_SQL: &str = r#"
SELECT
(
SELECT CAST(COUNT(cache_hit_usage.id) AS BIGINT)
FROM usage_billing_facts AS cache_hit_usage
WHERE cache_hit_usage.created_at >= $1
AND cache_hit_usage.created_at < $2
) AS cache_hit_total_requests,
(
SELECT CAST(
COUNT(cache_hit_usage.id) FILTER (
WHERE GREATEST(COALESCE(cache_hit_usage.cache_read_input_tokens, 0), 0) > 0
) AS BIGINT
)
FROM usage_billing_facts AS cache_hit_usage
WHERE cache_hit_usage.created_at >= $1
AND cache_hit_usage.created_at < $2
) AS cache_hit_requests,
(
SELECT CAST(COUNT(completed_usage.id) AS BIGINT)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_total_requests,
(
SELECT CAST(
COUNT(completed_usage.id) FILTER (
WHERE GREATEST(COALESCE(completed_usage.cache_read_input_tokens, 0), 0) > 0
) AS BIGINT
)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_cache_hit_requests,
(
SELECT CAST(
COALESCE(SUM(GREATEST(COALESCE(completed_usage.input_tokens, 0), 0)), 0) AS BIGINT
)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_input_tokens,
(
SELECT CAST(
COALESCE(
SUM(
CASE
WHEN COALESCE(completed_usage.cache_creation_input_tokens, 0) = 0
AND (
COALESCE(completed_usage.cache_creation_input_tokens_5m, 0)
+ COALESCE(completed_usage.cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(completed_usage.cache_creation_input_tokens_5m, 0)
+ COALESCE(completed_usage.cache_creation_input_tokens_1h, 0)
ELSE COALESCE(completed_usage.cache_creation_input_tokens, 0)
END
),
0
) AS BIGINT
)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_cache_creation_tokens,
(
SELECT CAST(
COALESCE(
SUM(GREATEST(COALESCE(completed_usage.cache_read_input_tokens, 0), 0)),
0
) AS BIGINT
)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_cache_read_tokens,
(
SELECT CAST(
COALESCE(
SUM(
CASE
WHEN split_part(
lower(
COALESCE(
COALESCE(
completed_usage.endpoint_api_format,
completed_usage.api_format
),
''
)
),
':',
1
) IN ('claude', 'anthropic')
THEN GREATEST(COALESCE(completed_usage.input_tokens, 0), 0)
+ CASE
WHEN COALESCE(completed_usage.cache_creation_input_tokens, 0) = 0
AND (
COALESCE(completed_usage.cache_creation_input_tokens_5m, 0)
+ COALESCE(completed_usage.cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(completed_usage.cache_creation_input_tokens_5m, 0)
+ COALESCE(completed_usage.cache_creation_input_tokens_1h, 0)
ELSE COALESCE(completed_usage.cache_creation_input_tokens, 0)
END
+ GREATEST(COALESCE(completed_usage.cache_read_input_tokens, 0), 0)
WHEN split_part(
lower(
COALESCE(
COALESCE(
completed_usage.endpoint_api_format,
completed_usage.api_format
),
''
)
),
':',
1
) IN ('openai', 'gemini', 'google')
THEN (
CASE
WHEN GREATEST(COALESCE(completed_usage.input_tokens, 0), 0) <= 0
THEN 0
WHEN GREATEST(
COALESCE(completed_usage.cache_read_input_tokens, 0),
0
) <= 0
THEN GREATEST(COALESCE(completed_usage.input_tokens, 0), 0)
ELSE GREATEST(
GREATEST(COALESCE(completed_usage.input_tokens, 0), 0)
- GREATEST(
COALESCE(completed_usage.cache_read_input_tokens, 0),
0
),
0
)
END
) + GREATEST(COALESCE(completed_usage.cache_read_input_tokens, 0), 0)
ELSE CASE
WHEN (
CASE
WHEN COALESCE(
completed_usage.cache_creation_input_tokens,
0
) = 0
AND (
COALESCE(
completed_usage.cache_creation_input_tokens_5m,
0
)
+ COALESCE(
completed_usage.cache_creation_input_tokens_1h,
0
)
) > 0
THEN COALESCE(
completed_usage.cache_creation_input_tokens_5m,
0
)
+ COALESCE(
completed_usage.cache_creation_input_tokens_1h,
0
)
ELSE COALESCE(
completed_usage.cache_creation_input_tokens,
0
)
END
) > 0
THEN GREATEST(COALESCE(completed_usage.input_tokens, 0), 0)
+ (
CASE
WHEN COALESCE(
completed_usage.cache_creation_input_tokens,
0
) = 0
AND (
COALESCE(
completed_usage.cache_creation_input_tokens_5m,
0
)
+ COALESCE(
completed_usage.cache_creation_input_tokens_1h,
0
)
) > 0
THEN COALESCE(
completed_usage.cache_creation_input_tokens_5m,
0
)
+ COALESCE(
completed_usage.cache_creation_input_tokens_1h,
0
)
ELSE COALESCE(
completed_usage.cache_creation_input_tokens,
0
)
END
)
+ GREATEST(COALESCE(completed_usage.cache_read_input_tokens, 0), 0)
ELSE GREATEST(COALESCE(completed_usage.input_tokens, 0), 0)
+ GREATEST(COALESCE(completed_usage.cache_read_input_tokens, 0), 0)
END
END
),
0
) AS BIGINT
)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_total_input_context,
(
SELECT CAST(
COALESCE(
SUM(
COALESCE(
CAST(completed_usage.cache_creation_cost_usd AS DOUBLE PRECISION),
0
)
),
0
) AS DOUBLE PRECISION
)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_cache_creation_cost,
(
SELECT CAST(
COALESCE(
SUM(
COALESCE(CAST(completed_usage.cache_read_cost_usd AS DOUBLE PRECISION), 0)
),
0
) AS DOUBLE PRECISION
)
FROM usage_billing_facts AS completed_usage
WHERE completed_usage.created_at >= $1
AND completed_usage.created_at < $2
AND completed_usage.status = 'completed'
) AS completed_cache_read_cost,
(
SELECT CAST(
COALESCE(SUM(COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0)), 0)
AS DOUBLE PRECISION
)
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_total_cost,
(
SELECT CAST(COUNT(settled_usage.id) AS BIGINT)
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_total_requests,
(
SELECT CAST(
COALESCE(SUM(GREATEST(COALESCE(settled_usage.input_tokens, 0), 0)), 0) AS BIGINT
)
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_input_tokens,
(
SELECT CAST(
COALESCE(SUM(GREATEST(COALESCE(settled_usage.output_tokens, 0), 0)), 0) AS BIGINT
)
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_output_tokens,
(
SELECT CAST(
COALESCE(
SUM(GREATEST(COALESCE(settled_usage.cache_creation_input_tokens, 0), 0)),
0
) AS BIGINT
)
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_cache_creation_tokens,
(
SELECT CAST(
COALESCE(
SUM(GREATEST(COALESCE(settled_usage.cache_read_input_tokens, 0), 0)),
0
) AS BIGINT
)
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_cache_read_tokens,
(
SELECT MIN(CAST(EXTRACT(EPOCH FROM settled_usage.finalized_at) AS BIGINT))
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_first_finalized_at_unix_secs,
(
SELECT MAX(CAST(EXTRACT(EPOCH FROM settled_usage.finalized_at) AS BIGINT))
FROM usage_billing_facts AS settled_usage
WHERE settled_usage.created_at >= $1
AND settled_usage.created_at < $2
AND settled_usage.billing_status = 'settled'
AND COALESCE(CAST(settled_usage.total_cost_usd AS DOUBLE PRECISION), 0) > 0
) AS settled_last_finalized_at_unix_secs,
CAST(COUNT(id) AS BIGINT) AS total_requests,
CAST(COALESCE(
SUM(
CASE
WHEN status_code >= 400
OR lower(COALESCE(status, '')) = 'failed'
OR error_message IS NOT NULL THEN 1
ELSE 0
END
),
0
) AS BIGINT) AS error_requests,
CAST(COALESCE(SUM(input_tokens), 0) AS BIGINT) AS input_tokens,
CAST(COALESCE(SUM(output_tokens), 0) AS BIGINT) AS output_tokens,
CAST(COALESCE(
SUM(
CASE
WHEN COALESCE(cache_creation_input_tokens, 0) = 0
AND (
COALESCE(cache_creation_input_tokens_5m, 0)
+ COALESCE(cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(cache_creation_input_tokens_5m, 0)
+ COALESCE(cache_creation_input_tokens_1h, 0)
ELSE COALESCE(cache_creation_input_tokens, 0)
END
),
0
) AS BIGINT) AS cache_creation_tokens,
CAST(COALESCE(SUM(cache_read_input_tokens), 0) AS BIGINT) AS cache_read_tokens,
CAST(COALESCE(SUM(total_cost_usd), 0) AS DOUBLE PRECISION) AS total_cost,
CAST(COALESCE(SUM(actual_total_cost_usd), 0) AS DOUBLE PRECISION) AS actual_total_cost,
COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL
THEN GREATEST(COALESCE(response_time_ms, 0), 0)::DOUBLE PRECISION
ELSE 0
END
),
0
) AS response_time_sum_ms,
CAST(COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL THEN 1
ELSE 0
END
),
0
) AS BIGINT) AS response_time_samples,
CAST(COALESCE(AVG(response_time_ms), 0) AS DOUBLE PRECISION) AS avg_response_time_ms
FROM usage_billing_facts AS usage
WHERE created_at >= $1
AND created_at < $2
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#;
pub(super) const UPSERT_STATS_HOURLY_SQL: &str = r#"
INSERT INTO stats_hourly (
id,
hour_utc,
total_requests,
cache_hit_total_requests,
cache_hit_requests,
completed_total_requests,
completed_cache_hit_requests,
completed_input_tokens,
completed_cache_creation_tokens,
completed_cache_read_tokens,
completed_total_input_context,
completed_cache_creation_cost,
completed_cache_read_cost,
settled_total_cost,
settled_total_requests,
settled_input_tokens,
settled_output_tokens,
settled_cache_creation_tokens,
settled_cache_read_tokens,
settled_first_finalized_at_unix_secs,
settled_last_finalized_at_unix_secs,
success_requests,
error_requests,
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
total_cost,
actual_total_cost,
response_time_sum_ms,
response_time_samples,
avg_response_time_ms,
is_complete,
aggregated_at,
created_at,
updated_at
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8,
$9, $10, $11, $12, $13, $14, $15, $16,
$17, $18, $19, $20, $21, $22, $23, $24,
$25, $26, $27, $28, $29, $30, $31, $32,
$33, $34, $35, $36
)
ON CONFLICT (hour_utc)
DO UPDATE SET
total_requests = EXCLUDED.total_requests,
cache_hit_total_requests = EXCLUDED.cache_hit_total_requests,
cache_hit_requests = EXCLUDED.cache_hit_requests,
completed_total_requests = EXCLUDED.completed_total_requests,
completed_cache_hit_requests = EXCLUDED.completed_cache_hit_requests,
completed_input_tokens = EXCLUDED.completed_input_tokens,
completed_cache_creation_tokens = EXCLUDED.completed_cache_creation_tokens,
completed_cache_read_tokens = EXCLUDED.completed_cache_read_tokens,
completed_total_input_context = EXCLUDED.completed_total_input_context,
completed_cache_creation_cost = EXCLUDED.completed_cache_creation_cost,
completed_cache_read_cost = EXCLUDED.completed_cache_read_cost,
settled_total_cost = EXCLUDED.settled_total_cost,
settled_total_requests = EXCLUDED.settled_total_requests,
settled_input_tokens = EXCLUDED.settled_input_tokens,
settled_output_tokens = EXCLUDED.settled_output_tokens,
settled_cache_creation_tokens = EXCLUDED.settled_cache_creation_tokens,
settled_cache_read_tokens = EXCLUDED.settled_cache_read_tokens,
settled_first_finalized_at_unix_secs = EXCLUDED.settled_first_finalized_at_unix_secs,
settled_last_finalized_at_unix_secs = EXCLUDED.settled_last_finalized_at_unix_secs,
success_requests = EXCLUDED.success_requests,
error_requests = EXCLUDED.error_requests,
input_tokens = EXCLUDED.input_tokens,
output_tokens = EXCLUDED.output_tokens,
cache_creation_tokens = EXCLUDED.cache_creation_tokens,
cache_read_tokens = EXCLUDED.cache_read_tokens,
total_cost = EXCLUDED.total_cost,
actual_total_cost = EXCLUDED.actual_total_cost,
response_time_sum_ms = EXCLUDED.response_time_sum_ms,
response_time_samples = EXCLUDED.response_time_samples,
avg_response_time_ms = EXCLUDED.avg_response_time_ms,
is_complete = EXCLUDED.is_complete,
aggregated_at = EXCLUDED.aggregated_at,
updated_at = EXCLUDED.updated_at
"#;
pub(super) const UPSERT_STATS_HOURLY_USER_SQL: &str = r#"
WITH aggregated AS (
SELECT
user_id,
CAST(COUNT(id) AS BIGINT) AS total_requests,
CAST(COALESCE(
SUM(
CASE
WHEN status_code >= 400
OR lower(COALESCE(status, '')) = 'failed'
OR error_message IS NOT NULL THEN 1
ELSE 0
END
),
0
) AS BIGINT) AS error_requests,
CAST(COALESCE(SUM(input_tokens), 0) AS BIGINT) AS input_tokens,
CAST(COALESCE(SUM(output_tokens), 0) AS BIGINT) AS output_tokens,
CAST(COALESCE(
SUM(
CASE
WHEN COALESCE(cache_creation_input_tokens, 0) = 0
AND (
COALESCE(cache_creation_input_tokens_5m, 0)
+ COALESCE(cache_creation_input_tokens_1h, 0)
) > 0
THEN COALESCE(cache_creation_input_tokens_5m, 0)
+ COALESCE(cache_creation_input_tokens_1h, 0)
ELSE COALESCE(cache_creation_input_tokens, 0)
END
),
0
) AS BIGINT) AS cache_creation_tokens,
CAST(COALESCE(SUM(cache_read_input_tokens), 0) AS BIGINT) AS cache_read_tokens,
CAST(COALESCE(SUM(total_cost_usd), 0) AS DOUBLE PRECISION) AS total_cost,
CAST(COALESCE(SUM(actual_total_cost_usd), 0) AS DOUBLE PRECISION) AS actual_total_cost,
CAST(
COALESCE(
SUM(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
THEN COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0)
ELSE 0
END
),
0
) AS DOUBLE PRECISION
) AS settled_total_cost,
CAST(COALESCE(
SUM(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
THEN 1
ELSE 0
END
),
0
) AS BIGINT) AS settled_total_requests,
CAST(COALESCE(
SUM(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
THEN GREATEST(COALESCE(input_tokens, 0), 0)
ELSE 0
END
),
0
) AS BIGINT) AS settled_input_tokens,
CAST(COALESCE(
SUM(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
THEN GREATEST(COALESCE(output_tokens, 0), 0)
ELSE 0
END
),
0
) AS BIGINT) AS settled_output_tokens,
CAST(COALESCE(
SUM(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
THEN GREATEST(COALESCE(cache_creation_input_tokens, 0), 0)
ELSE 0
END
),
0
) AS BIGINT) AS settled_cache_creation_tokens,
CAST(COALESCE(
SUM(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
THEN GREATEST(COALESCE(cache_read_input_tokens, 0), 0)
ELSE 0
END
),
0
) AS BIGINT) AS settled_cache_read_tokens,
MIN(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
AND finalized_at IS NOT NULL
THEN CAST(EXTRACT(EPOCH FROM finalized_at) AS BIGINT)
ELSE NULL
END
) AS settled_first_finalized_at_unix_secs,
MAX(
CASE
WHEN billing_status = 'settled'
AND COALESCE(CAST(total_cost_usd AS DOUBLE PRECISION), 0) > 0
AND finalized_at IS NOT NULL
THEN CAST(EXTRACT(EPOCH FROM finalized_at) AS BIGINT)
ELSE NULL
END
) AS settled_last_finalized_at_unix_secs,
COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL
THEN GREATEST(COALESCE(response_time_ms, 0), 0)::DOUBLE PRECISION
ELSE 0
END
),
0
) AS response_time_sum_ms,
CAST(COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL THEN 1
ELSE 0
END
),
0
) AS BIGINT) AS response_time_samples
FROM usage_billing_facts AS usage
WHERE created_at >= $1
AND created_at < $2
AND user_id IS NOT NULL
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
GROUP BY user_id
)
INSERT INTO stats_hourly_user (
id,
hour_utc,
user_id,
total_requests,
success_requests,
error_requests,
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
total_cost,
actual_total_cost,
settled_total_cost,
settled_total_requests,
settled_input_tokens,
settled_output_tokens,
settled_cache_creation_tokens,
settled_cache_read_tokens,
settled_first_finalized_at_unix_secs,
settled_last_finalized_at_unix_secs,
response_time_sum_ms,
response_time_samples,
created_at,
updated_at
)
SELECT
md5(CONCAT('stats-hourly-user:', aggregated.user_id, ':', CAST($1 AS TEXT))),
$1,
aggregated.user_id,
aggregated.total_requests,
GREATEST(aggregated.total_requests - aggregated.error_requests, 0),
aggregated.error_requests,
aggregated.input_tokens,
aggregated.output_tokens,
aggregated.cache_creation_tokens,
aggregated.cache_read_tokens,
aggregated.total_cost,
aggregated.actual_total_cost,
aggregated.settled_total_cost,
aggregated.settled_total_requests,
aggregated.settled_input_tokens,
aggregated.settled_output_tokens,
aggregated.settled_cache_creation_tokens,
aggregated.settled_cache_read_tokens,
aggregated.settled_first_finalized_at_unix_secs,
aggregated.settled_last_finalized_at_unix_secs,
aggregated.response_time_sum_ms,
aggregated.response_time_samples,
$3,
$3
FROM aggregated
ON CONFLICT (hour_utc, user_id)
DO UPDATE SET
total_requests = EXCLUDED.total_requests,
success_requests = EXCLUDED.success_requests,
error_requests = EXCLUDED.error_requests,
input_tokens = EXCLUDED.input_tokens,
output_tokens = EXCLUDED.output_tokens,
cache_creation_tokens = EXCLUDED.cache_creation_tokens,
cache_read_tokens = EXCLUDED.cache_read_tokens,
total_cost = EXCLUDED.total_cost,
actual_total_cost = EXCLUDED.actual_total_cost,
settled_total_cost = EXCLUDED.settled_total_cost,
settled_total_requests = EXCLUDED.settled_total_requests,
settled_input_tokens = EXCLUDED.settled_input_tokens,
settled_output_tokens = EXCLUDED.settled_output_tokens,
settled_cache_creation_tokens = EXCLUDED.settled_cache_creation_tokens,
settled_cache_read_tokens = EXCLUDED.settled_cache_read_tokens,
settled_first_finalized_at_unix_secs = EXCLUDED.settled_first_finalized_at_unix_secs,
settled_last_finalized_at_unix_secs = EXCLUDED.settled_last_finalized_at_unix_secs,
response_time_sum_ms = EXCLUDED.response_time_sum_ms,
response_time_samples = EXCLUDED.response_time_samples,
updated_at = EXCLUDED.updated_at
"#;
pub(super) const UPSERT_STATS_HOURLY_MODEL_SQL: &str = r#"
WITH aggregated AS (
SELECT
model,
CAST(COUNT(id) AS BIGINT) AS total_requests,
CAST(COALESCE(SUM(input_tokens), 0) AS BIGINT) AS input_tokens,
CAST(COALESCE(SUM(output_tokens), 0) AS BIGINT) AS output_tokens,
CAST(COALESCE(SUM(total_cost_usd), 0) AS DOUBLE PRECISION) AS total_cost,
COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL
THEN GREATEST(COALESCE(response_time_ms, 0), 0)::DOUBLE PRECISION
ELSE 0
END
),
0
) AS response_time_sum_ms,
CAST(COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL THEN 1
ELSE 0
END
),
0
) AS BIGINT) AS response_time_samples,
CAST(COALESCE(AVG(response_time_ms), 0) AS DOUBLE PRECISION) AS avg_response_time_ms
FROM usage_billing_facts AS usage
WHERE created_at >= $1
AND created_at < $2
AND model IS NOT NULL
AND model <> ''
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
GROUP BY model
)
INSERT INTO stats_hourly_model (
id,
hour_utc,
model,
total_requests,
input_tokens,
output_tokens,
total_cost,
response_time_sum_ms,
response_time_samples,
avg_response_time_ms,
created_at,
updated_at
)
SELECT
md5(CONCAT('stats-hourly-model:', aggregated.model, ':', CAST($1 AS TEXT))),
$1,
aggregated.model,
aggregated.total_requests,
aggregated.input_tokens,
aggregated.output_tokens,
aggregated.total_cost,
aggregated.response_time_sum_ms,
aggregated.response_time_samples,
aggregated.avg_response_time_ms,
$3,
$3
FROM aggregated
ON CONFLICT (hour_utc, model)
DO UPDATE SET
total_requests = EXCLUDED.total_requests,
input_tokens = EXCLUDED.input_tokens,
output_tokens = EXCLUDED.output_tokens,
total_cost = EXCLUDED.total_cost,
response_time_sum_ms = EXCLUDED.response_time_sum_ms,
response_time_samples = EXCLUDED.response_time_samples,
avg_response_time_ms = EXCLUDED.avg_response_time_ms,
updated_at = EXCLUDED.updated_at
"#;
pub(super) const UPSERT_STATS_HOURLY_USER_MODEL_SQL: &str = r#"
WITH aggregated AS (
SELECT
user_id,
model,
CAST(COUNT(id) AS BIGINT) AS total_requests,
CAST(COALESCE(SUM(input_tokens), 0) AS BIGINT) AS input_tokens,
CAST(COALESCE(SUM(output_tokens), 0) AS BIGINT) AS output_tokens,
CAST(COALESCE(SUM(total_cost_usd), 0) AS DOUBLE PRECISION) AS total_cost,
COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL
THEN GREATEST(COALESCE(response_time_ms, 0), 0)::DOUBLE PRECISION
ELSE 0
END
),
0
) AS response_time_sum_ms,
CAST(COALESCE(
SUM(
CASE
WHEN response_time_ms IS NOT NULL THEN 1
ELSE 0
END
),
0
) AS BIGINT) AS response_time_samples
FROM usage_billing_facts AS usage
WHERE created_at >= $1
AND created_at < $2
AND user_id IS NOT NULL
AND model IS NOT NULL
AND model <> ''
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
GROUP BY user_id, model
)
INSERT INTO stats_hourly_user_model (
id,
hour_utc,
user_id,
model,
total_requests,
input_tokens,
output_tokens,
total_cost,
response_time_sum_ms,
response_time_samples,
created_at,
updated_at
)
SELECT
md5(CONCAT('stats-hourly-user-model:', aggregated.user_id, ':', aggregated.model, ':', CAST($1 AS TEXT))),
$1,
aggregated.user_id,
aggregated.model,
aggregated.total_requests,
aggregated.input_tokens,
aggregated.output_tokens,
aggregated.total_cost,
aggregated.response_time_sum_ms,
aggregated.response_time_samples,
$3,
$3
FROM aggregated
ON CONFLICT (hour_utc, user_id, model)
DO UPDATE SET
total_requests = EXCLUDED.total_requests,
input_tokens = EXCLUDED.input_tokens,
output_tokens = EXCLUDED.output_tokens,
total_cost = EXCLUDED.total_cost,
response_time_sum_ms = EXCLUDED.response_time_sum_ms,
response_time_samples = EXCLUDED.response_time_samples,
updated_at = EXCLUDED.updated_at
"#;
pub(super) const UPSERT_STATS_HOURLY_PROVIDER_SQL: &str = r#"
WITH aggregated AS (
SELECT
provider_name,
CAST(COUNT(id) AS BIGINT) AS total_requests,
CAST(COALESCE(SUM(input_tokens), 0) AS BIGINT) AS input_tokens,
CAST(COALESCE(SUM(output_tokens), 0) AS BIGINT) AS output_tokens,
CAST(COALESCE(SUM(total_cost_usd), 0) AS DOUBLE PRECISION) AS total_cost
FROM usage_billing_facts AS usage
WHERE created_at >= $1
AND created_at < $2
AND provider_name IS NOT NULL
AND provider_name <> ''
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
GROUP BY provider_name
)
INSERT INTO stats_hourly_provider (
id,
hour_utc,
provider_name,
total_requests,
input_tokens,
output_tokens,
total_cost,
created_at,
updated_at
)
SELECT
md5(CONCAT('stats-hourly-provider:', aggregated.provider_name, ':', CAST($1 AS TEXT))),
$1,
aggregated.provider_name,
aggregated.total_requests,
aggregated.input_tokens,
aggregated.output_tokens,
aggregated.total_cost,
$3,
$3
FROM aggregated
ON CONFLICT (hour_utc, provider_name)
DO UPDATE SET
total_requests = EXCLUDED.total_requests,
input_tokens = EXCLUDED.input_tokens,
output_tokens = EXCLUDED.output_tokens,
total_cost = EXCLUDED.total_cost,
updated_at = EXCLUDED.updated_at
"#;
@@ -0,0 +1,379 @@
use chrono::{DateTime, Utc};
use sqlx::Row;
use crate::backend::stats_common::{stats_id, unix_ms, unix_secs, utc_from_unix_secs};
use crate::backend::SqliteBackend;
use crate::driver::sqlite::SqlitePool;
use crate::error::SqlResultExt;
use crate::{
DataLayerError, StatsDailyAggregationInput, StatsDailyAggregationSummary,
StatsHourlyAggregationInput, StatsHourlyAggregationSummary,
};
impl SqliteBackend {
pub async fn aggregate_stats_hourly(
&self,
input: &StatsHourlyAggregationInput,
) -> Result<Option<StatsHourlyAggregationSummary>, DataLayerError> {
let Some(hour_utc_unix_secs) =
next_sqlite_stats_hourly_bucket(self.pool(), input.target_hour_utc).await?
else {
return Ok(None);
};
perform_sqlite_stats_hourly_aggregation(
self.pool(),
hour_utc_unix_secs,
input.aggregated_at,
)
.await
.map(Some)
}
pub async fn aggregate_stats_daily(
&self,
input: &StatsDailyAggregationInput,
) -> Result<Option<StatsDailyAggregationSummary>, DataLayerError> {
let Some(day_start_unix_secs) =
next_sqlite_stats_daily_bucket(self.pool(), input.target_day_utc).await?
else {
return Ok(None);
};
perform_sqlite_stats_daily_aggregation(
self.pool(),
day_start_unix_secs,
input.aggregated_at,
)
.await
.map(Some)
}
}
async fn next_sqlite_stats_hourly_bucket(
pool: &SqlitePool,
target_hour_utc: DateTime<Utc>,
) -> Result<Option<i64>, DataLayerError> {
let latest_hour: Option<i64> =
sqlx::query_scalar("SELECT MAX(hour_utc) FROM stats_hourly WHERE is_complete <> 0")
.fetch_one(pool)
.await
.map_sql_err()?;
let search_from = latest_hour.map(|value| value + 3600).unwrap_or(0);
let search_until = unix_secs(target_hour_utc) + 3600;
if search_from >= search_until {
return Ok(None);
}
let next_bucket: Option<i64> = sqlx::query_scalar(
r#"
SELECT MIN(CAST(created_at_unix_ms / 3600000 AS INTEGER) * 3600)
FROM "usage"
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#,
)
.bind(unix_ms(search_from)?)
.bind(unix_ms(search_until)?)
.fetch_one(pool)
.await
.map_sql_err()?;
Ok(next_bucket.filter(|value| *value <= unix_secs(target_hour_utc)))
}
async fn next_sqlite_stats_daily_bucket(
pool: &SqlitePool,
target_day_utc: DateTime<Utc>,
) -> Result<Option<i64>, DataLayerError> {
let latest_day: Option<i64> =
sqlx::query_scalar(r#"SELECT MAX("date") FROM stats_daily WHERE is_complete <> 0"#)
.fetch_one(pool)
.await
.map_sql_err()?;
let search_from = latest_day.map(|value| value + 86_400).unwrap_or(0);
let search_until = unix_secs(target_day_utc) + 86_400;
if search_from >= search_until {
return Ok(None);
}
let next_bucket: Option<i64> = sqlx::query_scalar(
r#"
SELECT MIN(CAST(created_at_unix_ms / 86400000 AS INTEGER) * 86400)
FROM "usage"
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#,
)
.bind(unix_ms(search_from)?)
.bind(unix_ms(search_until)?)
.fetch_one(pool)
.await
.map_sql_err()?;
Ok(next_bucket.filter(|value| *value <= unix_secs(target_day_utc)))
}
const SQLITE_STATS_AGGREGATE_SQL: &str = r#"
SELECT
COUNT(*) AS total_requests,
COALESCE(SUM(CASE
WHEN status = 'failed'
OR status_code >= 400
OR (error_category IS NOT NULL AND error_category <> '')
THEN 1 ELSE 0 END), 0) AS error_requests,
COALESCE(SUM(input_tokens), 0) AS input_tokens,
COALESCE(SUM(output_tokens), 0) AS output_tokens,
COALESCE(SUM(cache_creation_input_tokens), 0) AS cache_creation_tokens,
COALESCE(SUM(cache_read_input_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(total_cost_usd), 0.0) AS total_cost,
COALESCE(SUM(actual_total_cost_usd), 0.0) AS actual_total_cost,
COALESCE(AVG(response_time_ms), 0.0) AS avg_response_time_ms
FROM "usage"
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
"#;
async fn perform_sqlite_stats_hourly_aggregation(
pool: &SqlitePool,
hour_utc_unix_secs: i64,
aggregated_at: DateTime<Utc>,
) -> Result<StatsHourlyAggregationSummary, DataLayerError> {
let start_ms = unix_ms(hour_utc_unix_secs)?;
let end_ms = unix_ms(hour_utc_unix_secs + 3600)?;
let aggregated_at_unix_secs = unix_secs(aggregated_at);
let mut tx = pool.begin().await.map_sql_err()?;
let row = sqlx::query(SQLITE_STATS_AGGREGATE_SQL)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut *tx)
.await
.map_sql_err()?;
let total_requests: i64 = row.try_get("total_requests").map_sql_err()?;
let error_requests: i64 = row.try_get("error_requests").map_sql_err()?;
sqlx::query(
r#"
INSERT INTO stats_hourly (
id, hour_utc, total_requests, success_requests, error_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
total_cost, actual_total_cost, avg_response_time_ms, is_complete,
aggregated_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
ON CONFLICT (hour_utc) DO UPDATE SET
total_requests = excluded.total_requests,
success_requests = excluded.success_requests,
error_requests = excluded.error_requests,
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
cache_creation_tokens = excluded.cache_creation_tokens,
cache_read_tokens = excluded.cache_read_tokens,
total_cost = excluded.total_cost,
actual_total_cost = excluded.actual_total_cost,
avg_response_time_ms = excluded.avg_response_time_ms,
is_complete = excluded.is_complete,
aggregated_at = excluded.aggregated_at,
updated_at = excluded.updated_at
"#,
)
.bind(stats_id(&format!("stats-hourly:{hour_utc_unix_secs}")))
.bind(hour_utc_unix_secs)
.bind(total_requests)
.bind(total_requests.saturating_sub(error_requests))
.bind(error_requests)
.bind(row.try_get::<i64, _>("input_tokens").map_sql_err()?)
.bind(row.try_get::<i64, _>("output_tokens").map_sql_err()?)
.bind(
row.try_get::<i64, _>("cache_creation_tokens")
.map_sql_err()?,
)
.bind(row.try_get::<i64, _>("cache_read_tokens").map_sql_err()?)
.bind(row.try_get::<f64, _>("total_cost").map_sql_err()?)
.bind(row.try_get::<f64, _>("actual_total_cost").map_sql_err()?)
.bind(
row.try_get::<f64, _>("avg_response_time_ms")
.map_sql_err()?,
)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.execute(&mut *tx)
.await
.map_sql_err()?;
let user_rows = sqlite_group_count(&mut tx, "user_id", start_ms, end_ms).await?;
let user_model_rows = sqlite_group_count(&mut tx, "user_id, model", start_ms, end_ms).await?;
let model_rows = sqlite_group_count(&mut tx, "model", start_ms, end_ms).await?;
let provider_rows = sqlite_group_count(&mut tx, "provider_name", start_ms, end_ms).await?;
tx.commit().await.map_sql_err()?;
Ok(StatsHourlyAggregationSummary {
hour_utc: utc_from_unix_secs(hour_utc_unix_secs, "stats_hourly.hour_utc")?,
total_requests,
user_rows,
user_model_rows,
model_rows,
provider_rows,
})
}
async fn perform_sqlite_stats_daily_aggregation(
pool: &SqlitePool,
day_start_unix_secs: i64,
aggregated_at: DateTime<Utc>,
) -> Result<StatsDailyAggregationSummary, DataLayerError> {
let start_ms = unix_ms(day_start_unix_secs)?;
let end_ms = unix_ms(day_start_unix_secs + 86_400)?;
let aggregated_at_unix_secs = unix_secs(aggregated_at);
let mut tx = pool.begin().await.map_sql_err()?;
let row = sqlx::query(SQLITE_STATS_AGGREGATE_SQL)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut *tx)
.await
.map_sql_err()?;
let total_requests: i64 = row.try_get("total_requests").map_sql_err()?;
let error_requests: i64 = row.try_get("error_requests").map_sql_err()?;
let unique_models = sqlite_group_count(&mut tx, "model", start_ms, end_ms).await? as i64;
let unique_providers =
sqlite_group_count(&mut tx, "provider_name", start_ms, end_ms).await? as i64;
sqlx::query(
r#"
INSERT INTO stats_daily (
id, "date", total_requests, success_requests, error_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
total_cost, actual_total_cost, avg_response_time_ms, fallback_count,
unique_models, unique_providers, is_complete, aggregated_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, 1, ?, ?, ?)
ON CONFLICT ("date") DO UPDATE SET
total_requests = excluded.total_requests,
success_requests = excluded.success_requests,
error_requests = excluded.error_requests,
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
cache_creation_tokens = excluded.cache_creation_tokens,
cache_read_tokens = excluded.cache_read_tokens,
total_cost = excluded.total_cost,
actual_total_cost = excluded.actual_total_cost,
avg_response_time_ms = excluded.avg_response_time_ms,
fallback_count = excluded.fallback_count,
unique_models = excluded.unique_models,
unique_providers = excluded.unique_providers,
is_complete = excluded.is_complete,
aggregated_at = excluded.aggregated_at,
updated_at = excluded.updated_at
"#,
)
.bind(stats_id(&format!("stats-daily:{day_start_unix_secs}")))
.bind(day_start_unix_secs)
.bind(total_requests)
.bind(total_requests.saturating_sub(error_requests))
.bind(error_requests)
.bind(row.try_get::<i64, _>("input_tokens").map_sql_err()?)
.bind(row.try_get::<i64, _>("output_tokens").map_sql_err()?)
.bind(
row.try_get::<i64, _>("cache_creation_tokens")
.map_sql_err()?,
)
.bind(row.try_get::<i64, _>("cache_read_tokens").map_sql_err()?)
.bind(row.try_get::<f64, _>("total_cost").map_sql_err()?)
.bind(row.try_get::<f64, _>("actual_total_cost").map_sql_err()?)
.bind(
row.try_get::<f64, _>("avg_response_time_ms")
.map_sql_err()?,
)
.bind(unique_models)
.bind(unique_providers)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.bind(aggregated_at_unix_secs)
.execute(&mut *tx)
.await
.map_sql_err()?;
let model_rows = usize::try_from(unique_models).unwrap_or(usize::MAX);
let provider_rows = usize::try_from(unique_providers).unwrap_or(usize::MAX);
let api_key_rows = sqlite_group_count(&mut tx, "api_key_id", start_ms, end_ms).await?;
let error_rows = sqlite_error_group_count(&mut tx, start_ms, end_ms).await?;
let user_rows = sqlite_group_count(&mut tx, "user_id", start_ms, end_ms).await?;
tx.commit().await.map_sql_err()?;
Ok(StatsDailyAggregationSummary {
day_start_utc: utc_from_unix_secs(day_start_unix_secs, "stats_daily.date")?,
total_requests,
model_rows,
provider_rows,
api_key_rows,
error_rows,
user_rows,
})
}
async fn sqlite_group_count(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
group_columns: &str,
start_ms: i64,
end_ms: i64,
) -> Result<usize, DataLayerError> {
let not_empty = group_columns
.split(',')
.map(str::trim)
.map(|column| format!("{column} IS NOT NULL AND {column} <> ''"))
.collect::<Vec<_>>()
.join(" AND ");
let sql = format!(
r#"
SELECT COUNT(*)
FROM (
SELECT 1
FROM "usage"
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
AND {not_empty}
GROUP BY {group_columns}
)
"#
);
let count: i64 = sqlx::query_scalar(&sql)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut **tx)
.await
.map_sql_err()?;
Ok(usize::try_from(count.max(0)).unwrap_or(usize::MAX))
}
async fn sqlite_error_group_count(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
start_ms: i64,
end_ms: i64,
) -> Result<usize, DataLayerError> {
let count: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*)
FROM (
SELECT 1
FROM "usage"
WHERE created_at_unix_ms >= ?
AND created_at_unix_ms < ?
AND status NOT IN ('pending', 'streaming')
AND provider_name NOT IN ('unknown', 'pending')
AND (
status = 'failed'
OR status_code >= 400
OR (error_category IS NOT NULL AND error_category <> '')
)
GROUP BY COALESCE(NULLIF(error_category, ''), 'unknown_error'), provider_name, model
)
"#,
)
.bind(start_ms)
.bind(end_ms)
.fetch_one(&mut **tx)
.await
.map_sql_err()?;
Ok(usize::try_from(count.max(0)).unwrap_or(usize::MAX))
}