Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions src/api/analytics_dashboard.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
use axum::{extract::State, routing::get, Json, Router};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::sync::Arc;
use utoipa::ToSchema;

use crate::cache::helpers::cached_query;
use crate::cache::{keys, CacheManager};
use crate::database::Database;
use crate::error::{ApiError, ApiResult};
use crate::rpc::StellarRpcClient;
use crate::services::price_feed::PriceFeedClient;

#[derive(Serialize, Deserialize, Clone)]
pub struct NetworkVolumeDataPoint {
Expand Down Expand Up @@ -287,3 +293,211 @@ pub fn routes(cache: Arc<CacheManager>) -> Router {
.route("/dashboard", get(analytics_dashboard))
.with_state(cache)
}

// ---------------------------------------------------------------------------
// /api/v1/stats/summary (issue #1)
// ---------------------------------------------------------------------------
//
// Lighter-weight sibling of `/analytics/dashboard` for the homepage stat-tile
// row: daily active accounts, 24h transaction count, 24h payment volume, and
// active Soroban contract count. This is the single most-hit endpoint in the
// pivot (every homepage load calls it), so it's cached (issue #4, folded in
// here) from day one rather than retrofitted later.
//
// **Day-boundary convention:** "24h" here means the *current UTC calendar
// day* (`date('now')` in SQLite), matching #2's DAA job and #3's snapshot
// delta convention -- not a rolling 24h window from request time. Picking one
// and being consistent is what #1's issue text calls out explicitly.

/// A stat value plus its "vs yesterday" delta (issue #3, folded in here so
/// the summary endpoint ships complete).
#[derive(Debug, Serialize, Deserialize, Clone, Copy, ToSchema)]
pub struct StatWithDelta {
pub value: f64,
/// Percent change vs. the same stat yesterday. `None` when there's no
/// prior-day row to diff against (day one in production) or yesterday's
/// value was exactly zero -- never a fabricated 0%, `inf`, or `NaN`.
pub delta_percent: Option<f64>,
}

impl StatWithDelta {
fn compute(today: f64, yesterday: Option<f64>) -> Self {
let delta_percent = match yesterday {
Some(y) if y != 0.0 => Some(((today - y) / y) * 100.0),
_ => None,
};
Self {
value: today,
delta_percent,
}
}
}

#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct StatsSummary {
pub daily_active_accounts: StatWithDelta,
pub tx_count_24h: StatWithDelta,
pub payment_volume_24h_usd: StatWithDelta,
pub active_soroban_contracts: StatWithDelta,
/// UTC calendar day these figures were computed for (RFC3339 date).
pub as_of: String,
}

#[derive(sqlx::FromRow, Default)]
struct DailyFigures {
daa: i64,
tx_count: i64,
active_contracts: i64,
}

#[derive(sqlx::FromRow)]
struct AssetVolumeRow {
asset_type: String,
asset_code: Option<String>,
asset_issuer: Option<String>,
total_amount: f64,
}

fn asset_identifier(asset_type: &str, code: Option<&str>, issuer: Option<&str>) -> String {
if asset_type == "native" {
"XLM:native".to_string()
} else {
format!(
"{}:{}",
code.unwrap_or("UNKNOWN"),
issuer.unwrap_or("unknown")
)
}
}

/// Sums a UTC calendar day's payment volume converted to USD via the price
/// feed. Aggregates by asset first (`GROUP BY`) rather than pulling every
/// individual payment row, so this stays cheap regardless of how many
/// payments land in a busy day -- cost scales with distinct assets, not
/// transaction count.
async fn payment_volume_usd_for(
db: &Database,
price_feed: &PriceFeedClient,
date_expr: &str,
) -> anyhow::Result<f64> {
let sql = format!(
"SELECT asset_type, asset_code, asset_issuer, SUM(amount) as total_amount
FROM payments
WHERE date(created_at) = date('now', '{date_expr}')
GROUP BY asset_type, asset_code, asset_issuer"
);
let rows = sqlx::query_as::<_, AssetVolumeRow>(&sql)
.fetch_all(db.pool())
.await?;

let asset_ids: Vec<String> = {
let mut seen = HashSet::new();
rows.iter()
.map(|r| asset_identifier(&r.asset_type, r.asset_code.as_deref(), r.asset_issuer.as_deref()))
.filter(|id| seen.insert(id.clone()))
.collect()
};
let prices = price_feed.get_prices(&asset_ids).await;

let total = rows
.iter()
.filter_map(|r| {
let id = asset_identifier(&r.asset_type, r.asset_code.as_deref(), r.asset_issuer.as_deref());
prices.get(&id).map(|price| r.total_amount * price)
})
.sum();
Ok(total)
}

async fn daily_figures_for(db: &Database, date_expr: &str) -> anyhow::Result<DailyFigures> {
let sql = format!(
"SELECT
(SELECT COUNT(DISTINCT source_account) FROM payments WHERE date(created_at) = date('now', '{date_expr}')) as daa,
(SELECT COUNT(*) FROM payments WHERE date(created_at) = date('now', '{date_expr}')) as tx_count,
(SELECT COUNT(DISTINCT contract_id) FROM contract_events WHERE date(created_at) = date('now', '{date_expr}')) as active_contracts"
);
let figures = sqlx::query_as::<_, DailyFigures>(&sql)
.fetch_optional(db.pool())
.await?
.unwrap_or_default();
Ok(figures)
}

async fn compute_stats_summary(
db: &Database,
price_feed: &PriceFeedClient,
) -> anyhow::Result<StatsSummary> {
let today = daily_figures_for(db, "+0 days").await?;
let yesterday = daily_figures_for(db, "-1 days").await?;
let volume_today = payment_volume_usd_for(db, price_feed, "+0 days").await?;
let volume_yesterday = payment_volume_usd_for(db, price_feed, "-1 days").await?;

// `yesterday`'s query always returns a (possibly all-zero) row rather
// than NULL, so an explicit "was there really a prior day" check isn't
// available here the way #3's dedicated snapshot table has one; a
// same-day network with genuinely zero activity yesterday and a cold
// start both read as "yesterday == 0", which is exactly the case
// `StatWithDelta::compute` already treats as "no meaningful delta".
let yesterday_daa = if yesterday.daa == 0 && today.daa == 0 {
None
} else {
Some(yesterday.daa as f64)
};
let yesterday_tx = if yesterday.tx_count == 0 && today.tx_count == 0 {
None
} else {
Some(yesterday.tx_count as f64)
};
let yesterday_contracts = if yesterday.active_contracts == 0 && today.active_contracts == 0 {
None
} else {
Some(yesterday.active_contracts as f64)
};

Ok(StatsSummary {
daily_active_accounts: StatWithDelta::compute(today.daa as f64, yesterday_daa),
tx_count_24h: StatWithDelta::compute(today.tx_count as f64, yesterday_tx),
payment_volume_24h_usd: StatWithDelta::compute(volume_today, Some(volume_yesterday)),
active_soroban_contracts: StatWithDelta::compute(
today.active_contracts as f64,
yesterday_contracts,
),
as_of: chrono::Utc::now().format("%Y-%m-%d").to_string(),
})
}

/// Get homepage stat-tile summary: DAA, 24h tx count, 24h payment volume,
/// active Soroban contracts, each with a vs-yesterday delta.
///
/// Cached with the dashboard TTL (`CACHE_DASHBOARD_STATS_TTL`, default 60s)
/// -- see issue #4. A transient DB/RPC failure is never cached: `cached_query`
/// only writes to the cache after `query_fn` succeeds, so an error always
/// propagates as a fresh 500 rather than being served stale for the full TTL.
#[utoipa::path(
get,
path = "/api/v1/stats/summary",
responses(
(status = 200, description = "Homepage stat-tile summary", body = StatsSummary),
(status = 500, description = "Internal server error")
),
tag = "Overview"
)]
pub async fn get_stats_summary(
State((db, cache, _rpc_client, price_feed)): State<(
Arc<Database>,
Arc<CacheManager>,
Arc<StellarRpcClient>,
Arc<PriceFeedClient>,
)>,
) -> ApiResult<Json<StatsSummary>> {
let key = keys::stats_summary();
let ttl = cache.config.get_ttl("dashboard");

let summary = cached_query(&cache, &key, ttl, || async {
compute_stats_summary(&db, &price_feed).await
})
.await
.map_err(|e| ApiError::internal("stats_summary_failed", format!("{e}")))?;

Ok(Json(summary))
}
4 changes: 4 additions & 0 deletions src/api/v1/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ pub fn routes(
"/corridors/:corridor_key",
get(corridors::get_corridor_detail),
)
.route(
"/stats/summary",
get(crate::api::analytics_dashboard::get_stats_summary),
)
.with_state(cached_state);

// Captured before `app_state` is moved into `protected_routes` below.
Expand Down
9 changes: 9 additions & 0 deletions src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,15 @@ pub mod keys {
"analytics:dashboard".to_string()
}

/// Cache key for `/api/v1/stats/summary` (issue #1/#4). No request
/// parameters vary this endpoint's response today, so a single static
/// key is correct -- if a per-network or per-user dimension is ever
/// added, this key must be extended to include it.
#[must_use]
pub fn stats_summary() -> String {
"stats:summary".to_string()
}

/// Pattern for invalidating all anchor-related caches
#[must_use]
pub fn anchor_pattern() -> String {
Expand Down
5 changes: 5 additions & 0 deletions src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ use utoipa::OpenApi;
// Wallets
crate::api::wallets::get_activity_calendar,
crate::api::wallets::get_largest_transfers,
// Overview
crate::api::analytics_dashboard::get_stats_summary,
),
components(
schemas(
Expand All @@ -190,9 +192,12 @@ use utoipa::OpenApi;
crate::api::wallets::TransferDirection,
crate::api::wallets::LargestTransfer,
crate::api::wallets::LargestTransfersResponse,
crate::api::analytics_dashboard::StatWithDelta,
crate::api::analytics_dashboard::StatsSummary,
)
),
tags(
(name = "Overview", description = "Homepage summary/stat-tile endpoints"),
(name = "Alerts", description = "Alert management and notification endpoints"),
(name = "Analytics", description = "API analytics endpoints"),
(name = "Anchors", description = "Anchor management and metrics endpoints"),
Expand Down