mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 17:37:47 +08:00
163 lines
7.9 KiB
Rust
163 lines
7.9 KiB
Rust
use super::SqlxUsageReadRepository;
|
|||
|
|
use crate::error::SqlxResultExt;
|
||
|
|
use aether_data_contracts::{repository::usage::*, DataLayerError};
|
||
|
|
use chrono::{DateTime, NaiveDate, Timelike, Utc};
|
||
|
|
use serde_json::Value;
|
||
|
|
use sqlx::Row;
|
||
|
|
|
||
|
|
fn decode_metrics(mut value: Value) -> Result<DashboardSummaryMetrics, DataLayerError> {
|
||
|
|
let count = value
|
||
|
|
.get("request_count")
|
||
|
|
.and_then(Value::as_u64)
|
||
|
|
.unwrap_or(0);
|
||
|
|
let priced = value
|
||
|
|
.get("pricing_available_count")
|
||
|
|
.and_then(Value::as_u64)
|
||
|
|
.unwrap_or(0);
|
||
|
|
value["billable_amount"] = if count == 0 {
|
||
|
|
Value::String("0.00000000".into())
|
||
|
|
} else if priced == 0 {
|
||
|
|
Value::Null
|
||
|
|
} else {
|
||
|
|
// The SQL converts NUMERIC to text before JSON serialization; no f64 step.
|
||
|
|
value.get("billable_amount").cloned().unwrap_or(Value::Null)
|
||
|
|
};
|
||
|
|
serde_json::from_value(value)
|
||
|
|
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))
|
||
|
|
}
|
||
|
|
|
||
|
|
impl SqlxUsageReadRepository {
|
||
|
|
pub async fn query_dashboard_summary(
|
||
|
|
&self,
|
||
|
|
query: &UsageDashboardAnalyticsQuery,
|
||
|
|
) -> Result<StoredDashboardSummary, DataLayerError> {
|
||
|
|
query.validate()?;
|
||
|
|
let mut tx = self.pool.begin().await.map_postgres_err()?;
|
||
|
|
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY")
|
||
|
|
.execute(&mut *tx)
|
||
|
|
.await
|
||
|
|
.map_postgres_err()?;
|
||
|
|
sqlx::query("SET LOCAL statement_timeout='5s'")
|
||
|
|
.execute(&mut *tx)
|
||
|
|
.await
|
||
|
|
.map_postgres_err()?;
|
||
|
|
let state = sqlx::query("SELECT stats_since, CURRENT_TIMESTAMP AS now, (SELECT count(*) FROM users WHERE NOT is_deleted) AS users FROM dashboard_stats_state WHERE singleton")
|
||
|
|
.fetch_one(&mut *tx).await.map_postgres_err()?;
|
||
|
|
let since: DateTime<Utc> = state.try_get("stats_since").map_postgres_err()?;
|
||
|
|
let now: DateTime<Utc> = state.try_get("now").map_postgres_err()?;
|
||
|
|
let today_start = query.today_start(now)?;
|
||
|
|
let today_from = today_start.max(since);
|
||
|
|
let minute_from = today_from
|
||
|
|
.with_second(0)
|
||
|
|
.and_then(|v| v.with_nanosecond(0))
|
||
|
|
.expect("valid minute");
|
||
|
|
let metrics_rows = sqlx::query(r#"
|
||
|
|
WITH selected AS (
|
||
|
|
SELECT 'total' AS period, metrics FROM dashboard_stats_total
|
||
|
|
UNION ALL
|
||
|
|
SELECT 'today', metrics FROM dashboard_stats_minute WHERE bucket_start >= $1 AND bucket_start <= $2
|
||
|
|
), sums AS (
|
||
|
|
SELECT period, key, sum(value::numeric) AS amount FROM selected
|
||
|
|
CROSS JOIN LATERAL jsonb_each_text(metrics) GROUP BY period,key
|
||
|
|
) SELECT period, jsonb_object_agg(key, CASE WHEN key='billable_amount'
|
||
|
|
THEN to_jsonb(round(amount,8)::text) ELSE to_jsonb(amount) END) AS metrics
|
||
|
|
FROM sums GROUP BY period"#)
|
||
|
|
.bind(minute_from).bind(now).fetch_all(&mut *tx).await.map_postgres_err()?;
|
||
|
|
let mut today = decode_metrics(serde_json::json!({}))?;
|
||
|
|
let mut total = today.clone();
|
||
|
|
for row in metrics_rows {
|
||
|
|
let metrics = decode_metrics(row.try_get("metrics").map_postgres_err()?)?;
|
||
|
|
match row
|
||
|
|
.try_get::<String, _>("period")
|
||
|
|
.map_postgres_err()?
|
||
|
|
.as_str()
|
||
|
|
{
|
||
|
|
"today" => today = metrics,
|
||
|
|
_ => total = metrics,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
today.active_users = sqlx::query_scalar::<_,i64>("SELECT count(DISTINCT a.actor_user_id) FROM dashboard_actor_minute a JOIN users u ON u.id=a.actor_user_id AND NOT u.is_deleted WHERE a.bucket_start >= $1 AND a.bucket_start <= $2 AND a.request_count > 0")
|
||
|
|
.bind(minute_from).bind(now).fetch_one(&mut *tx).await.map_postgres_err()? as u64;
|
||
|
|
// Most UTC hours belong to one local day. Only hours straddling a local
|
||
|
|
// midnight (e.g. Kathmandu) need their minute counts read. Lifetime
|
||
|
|
// activity therefore scans narrow hourly counters, not metric JSON.
|
||
|
|
let days = sqlx::query(r#"WITH hours AS MATERIALIZED (
|
||
|
|
SELECT bucket_start,shard,request_count,
|
||
|
|
(bucket_start AT TIME ZONE $1)::date AS day,
|
||
|
|
((bucket_start+INTERVAL '1 hour'-INTERVAL '1 microsecond') AT TIME ZONE $1)::date AS last_day
|
||
|
|
FROM dashboard_activity_hour WHERE request_count>0 AND bucket_start <= $2
|
||
|
|
), daily AS (
|
||
|
|
SELECT day, request_count AS requests FROM hours WHERE day=last_day
|
||
|
|
UNION ALL
|
||
|
|
SELECT (m.bucket_start AT TIME ZONE $1)::date, m.request_count
|
||
|
|
FROM hours h JOIN LATERAL (
|
||
|
|
SELECT bucket_start,request_count FROM dashboard_activity_minute
|
||
|
|
WHERE shard=h.shard AND bucket_start>=h.bucket_start
|
||
|
|
AND bucket_start<h.bucket_start+INTERVAL '1 hour' AND bucket_start<=$2
|
||
|
|
UNION ALL
|
||
|
|
SELECT w.bucket_start,COALESCE((w.metrics->>'request_count')::bigint,0)
|
||
|
|
FROM dashboard_stats_minute w
|
||
|
|
WHERE w.shard=h.shard AND w.bucket_start>=h.bucket_start
|
||
|
|
AND w.bucket_start<h.bucket_start+INTERVAL '1 hour' AND w.bucket_start<=$2
|
||
|
|
AND NOT EXISTS(SELECT 1 FROM dashboard_activity_minute n
|
||
|
|
WHERE n.bucket_start=w.bucket_start AND n.shard=w.shard)
|
||
|
|
OFFSET 0
|
||
|
|
) m ON TRUE WHERE h.day<>h.last_day
|
||
|
|
) SELECT day AS date, sum(requests)::bigint AS requests FROM daily
|
||
|
|
GROUP BY day HAVING sum(requests)>0 ORDER BY day"#)
|
||
|
|
.bind(&query.timezone).bind(now).fetch_all(&mut *tx).await.map_postgres_err()?;
|
||
|
|
let local_today = now
|
||
|
|
.with_timezone(
|
||
|
|
&query
|
||
|
|
.timezone
|
||
|
|
.parse::<chrono_tz::Tz>()
|
||
|
|
.map_err(|_| DataLayerError::InvalidInput("invalid timezone".into()))?,
|
||
|
|
)
|
||
|
|
.date_naive();
|
||
|
|
let first_heatmap_day = local_today - chrono::Duration::days(364);
|
||
|
|
let active_days = days.len() as u64;
|
||
|
|
let dates: Vec<NaiveDate> = days
|
||
|
|
.iter()
|
||
|
|
.map(|row| row.try_get("date").map_postgres_err())
|
||
|
|
.collect::<Result<_, _>>()?;
|
||
|
|
let consecutive_active_days =
|
||
|
|
dashboard_consecutive_active_days(dates.iter().copied(), local_today);
|
||
|
|
let mut activity_days = Vec::new();
|
||
|
|
for (row, date) in days.into_iter().zip(dates) {
|
||
|
|
if date >= first_heatmap_day {
|
||
|
|
activity_days.push(DashboardActivityDay {
|
||
|
|
date: date.to_string(),
|
||
|
|
requests: row.try_get::<i64, _>("requests").map_postgres_err()?.max(0) as u64,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let events = sqlx::query("SELECT COALESCE(sum(created_count),0)::bigint AS created, COALESCE(sum(deleted_count),0)::bigint AS deleted FROM dashboard_user_events_minute WHERE bucket_start >= $1 AND bucket_start <= $2")
|
||
|
|
.bind(minute_from).bind(now).fetch_one(&mut *tx).await.map_postgres_err()?;
|
||
|
|
let summary = StoredDashboardSummary {
|
||
|
|
stats_since: 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: state.try_get::<i64, _>("users").map_postgres_err()?.max(0) as u64,
|
||
|
|
created_today: events
|
||
|
|
.try_get::<i64, _>("created")
|
||
|
|
.map_postgres_err()?
|
||
|
|
.max(0) as u64,
|
||
|
|
deleted_today: events
|
||
|
|
.try_get::<i64, _>("deleted")
|
||
|
|
.map_postgres_err()?
|
||
|
|
.max(0) as u64,
|
||
|
|
},
|
||
|
|
active_days,
|
||
|
|
consecutive_active_days,
|
||
|
|
activity_days,
|
||
|
|
};
|
||
|
|
tx.commit().await.map_postgres_err()?;
|
||
|
|
Ok(summary)
|
||
|
|
}
|
||
|
|
}
|