diff --git a/README.md b/README.md index ee0ae1f9..ca8aafb9 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,13 @@ Copy the ULID key (or UUID key) out of the above command, and put it into the fo web3_proxy_cli --db-url ... change_user_tier_by_key "$RPC_ULID_KEY_FROM_PREV_COMMAND" "Unlimited" ``` +### Health compass + +Health check 3 servers and error if the first one doesn't match the others. + +``` +web3_proxy_cli https://eth.llamarpc.com/ https://rpc.ankr.com/eth https://cloudflare-eth.com +``` ## Database entities diff --git a/web3_proxy/src/bin/web3_proxy_cli/cost_calculator.rs b/web3_proxy/src/bin/web3_proxy_cli/cost_calculator.rs new file mode 100644 index 00000000..f2e6af2c --- /dev/null +++ b/web3_proxy/src/bin/web3_proxy_cli/cost_calculator.rs @@ -0,0 +1,238 @@ +use std::str::FromStr; + +// select all requests for a timeline. sum bandwidth and request count. give `cost / byte` and `cost / request`. +use anyhow::Context; +use argh::FromArgs; +use entities::{rpc_accounting, rpc_key, user}; +use ethers::types::Address; +use log::{debug, info}; +use migration::{ + sea_orm::{ + self, + prelude::{DateTimeUtc, Decimal}, + ColumnTrait, DatabaseConnection, EntityTrait, FromQueryResult, QueryFilter, QuerySelect, + }, + Condition, +}; + +#[derive(Debug, PartialEq, Eq)] +pub enum TimeFrame { + Day, + Month, +} + +impl TimeFrame { + pub fn as_seconds(&self) -> u64 { + match self { + Self::Day => 86_400, + Self::Month => 2_628_000, + } + } +} + +impl FromStr for TimeFrame { + type Err = anyhow::Error; + + fn from_str(s: &str) -> anyhow::Result { + match s.to_lowercase().as_ref() { + "day" => Ok(Self::Day), + "month" => Ok(Self::Month), + _ => Err(anyhow::anyhow!("Invalid string. Should be day or month.")), + } + } +} + +/// calculate costs +#[derive(FromArgs, PartialEq, Debug, Eq)] +#[argh(subcommand, name = "cost_calculator")] +pub struct CostCalculatorCommand { + /// dollar cost of running web3-proxy + #[argh(positional)] + cost: Decimal, + + #[argh(positional)] + cost_timeframe: TimeFrame, + + /// the address of the user to check. If none, check all. + /// TODO: allow checking a single key + #[argh(option)] + address: Option, + + /// the chain id to check. If none, check all. + #[argh(option)] + chain_id: Option, + // TODO: start and end dates? +} + +impl CostCalculatorCommand { + pub async fn main(self, db_conn: &DatabaseConnection) -> anyhow::Result<()> { + #[derive(Debug, FromQueryResult)] + struct SelectResult { + pub total_frontend_requests: Decimal, + pub total_backend_retries: Decimal, + pub total_cache_misses: Decimal, + pub total_cache_hits: Decimal, + pub total_response_bytes: Decimal, + pub total_error_responses: Decimal, + pub total_response_millis: Decimal, + pub first_period_datetime: DateTimeUtc, + pub last_period_datetime: DateTimeUtc, + } + + let q = rpc_accounting::Entity::find() + .select_only() + .column_as( + rpc_accounting::Column::FrontendRequests.sum(), + "total_frontend_requests", + ) + .column_as( + rpc_accounting::Column::BackendRequests.sum(), + "total_backend_retries", + ) + .column_as( + rpc_accounting::Column::CacheMisses.sum(), + "total_cache_misses", + ) + .column_as(rpc_accounting::Column::CacheHits.sum(), "total_cache_hits") + .column_as( + rpc_accounting::Column::SumResponseBytes.sum(), + "total_response_bytes", + ) + .column_as( + // TODO: can we sum bools like this? + rpc_accounting::Column::ErrorResponse.sum(), + "total_error_responses", + ) + .column_as( + rpc_accounting::Column::SumResponseMillis.sum(), + "total_response_millis", + ) + .column_as( + rpc_accounting::Column::PeriodDatetime.min(), + "first_period_datetime", + ) + .column_as( + rpc_accounting::Column::PeriodDatetime.max(), + "last_period_datetime", + ); + + let mut condition = Condition::all(); + + // TODO: do this with add_option? try operator is harder to use then + if let Some(address) = self.address { + let address: Vec = address.parse::
()?.to_fixed_bytes().into(); + + // TODO: find_with_related + let u = user::Entity::find() + .filter(user::Column::Address.eq(address)) + .one(db_conn) + .await? + .context("no user found")?; + + // TODO: select_only + let u_keys = rpc_key::Entity::find() + .filter(rpc_key::Column::UserId.eq(u.id)) + .all(db_conn) + .await?; + + if u_keys.is_empty() { + return Err(anyhow::anyhow!("no user keys")); + } + + let u_key_ids: Vec<_> = u_keys.iter().map(|x| x.id).collect(); + + condition = condition.add(rpc_accounting::Column::RpcKeyId.is_in(u_key_ids)); + } + + let q = q.filter(condition); + + // TODO: make this work without into_json. i think we need to make a struct + let query_response = q + .into_model::() + .one(db_conn) + .await? + .context("no query result")?; + + info!("query_response: {:#?}", query_response); + + // todo!("calculate cost/frontend request, cost/response_byte, calculate with a discount for cache hits"); + + let cost_seconds: Decimal = self.cost_timeframe.as_seconds().into(); + + debug!("cost_seconds: {}", cost_seconds); + + let query_seconds: Decimal = query_response + .last_period_datetime + .signed_duration_since(query_response.first_period_datetime) + .num_seconds() + .into(); + info!("query seconds: {}", query_seconds); + + let x = costs( + query_response.total_frontend_requests, + query_seconds, + cost_seconds, + self.cost, + ); + info!("${} = frontend request cost", x); + + let x = costs( + query_response.total_frontend_requests - query_response.total_error_responses, + query_seconds, + cost_seconds, + self.cost, + ); + info!("${} = frontend request cost excluding errors", x); + + let x = costs( + query_response.total_frontend_requests + - query_response.total_error_responses + - query_response.total_cache_hits, + query_seconds, + cost_seconds, + self.cost, + ); + info!( + "${} = frontend request cost excluding errors and cache hits", + x + ); + + let x = costs( + query_response.total_frontend_requests + - query_response.total_error_responses + - (query_response.total_cache_hits / Decimal::from(2)), + query_seconds, + cost_seconds, + self.cost, + ); + info!( + "${} = frontend request cost excluding errors and half cache hits", + x + ); + + let x = costs( + query_response.total_response_bytes, + query_seconds, + cost_seconds, + self.cost, + ); + info!("${} = response byte cost", x); + + // TODO: another script that takes these numbers and applies to a single user? + + Ok(()) + } +} + +fn costs( + query_total: Decimal, + query_seconds: Decimal, + cost_seconds: Decimal, + cost: Decimal, +) -> Decimal { + let requests_per_second = query_total / query_seconds; + let requests_per_cost_timeframe = requests_per_second * cost_seconds; + let request_cost_per_query = cost / requests_per_cost_timeframe; + + request_cost_per_query.round_dp(9) +} diff --git a/web3_proxy/src/bin/web3_proxy_cli/delete_user.rs b/web3_proxy/src/bin/web3_proxy_cli/delete_user.rs new file mode 100644 index 00000000..f157b5f0 --- /dev/null +++ b/web3_proxy/src/bin/web3_proxy_cli/delete_user.rs @@ -0,0 +1 @@ +//! delete user. don't delete rpc_accounting because we need that for our own accounting. have a "deleted" user that takes them over diff --git a/web3_proxy/src/bin/web3_proxy_cli/list_recent_users.rs b/web3_proxy/src/bin/web3_proxy_cli/list_recent_users.rs new file mode 100644 index 00000000..cb0a7020 --- /dev/null +++ b/web3_proxy/src/bin/web3_proxy_cli/list_recent_users.rs @@ -0,0 +1 @@ +//! List users that have recently made a request diff --git a/web3_proxy/src/bin/web3_proxy_cli/main.rs b/web3_proxy/src/bin/web3_proxy_cli/main.rs index a3a89dea..07696398 100644 --- a/web3_proxy/src/bin/web3_proxy_cli/main.rs +++ b/web3_proxy/src/bin/web3_proxy_cli/main.rs @@ -3,6 +3,7 @@ mod change_user_address_by_key; mod change_user_tier; mod change_user_tier_by_key; mod check_config; +mod cost_calculator; mod create_user; mod drop_migration_lock; mod health_compass; @@ -43,6 +44,7 @@ enum SubCommand { ChangeUserAddressByKey(change_user_address_by_key::ChangeUserAddressByKeyCommand), ChangeUserTier(change_user_tier::ChangeUserTierCommand), ChangeUserTierByKey(change_user_tier_by_key::ChangeUserTierByKeyCommand), + CostCalculatorCommand(cost_calculator::CostCalculatorCommand), CheckConfig(check_config::CheckConfigSubCommand), CreateUser(create_user::CreateUserSubCommand), DropMigrationLock(drop_migration_lock::DropMigrationLockSubCommand), @@ -110,6 +112,11 @@ async fn main() -> anyhow::Result<()> { x.main(&db_conn).await } + SubCommand::CostCalculatorCommand(x) => { + let db_conn = get_db(cli_config.db_url, 1, 1).await?; + + x.main(&db_conn).await + } SubCommand::DropMigrationLock(x) => { // very intentionally, do NOT run migrations here let db_conn = get_db(cli_config.db_url, 1, 1).await?; diff --git a/web3_proxy/src/bin/web3_proxy_cli/stat_age.rs b/web3_proxy/src/bin/web3_proxy_cli/stat_age.rs new file mode 100644 index 00000000..7f5e9757 --- /dev/null +++ b/web3_proxy/src/bin/web3_proxy_cli/stat_age.rs @@ -0,0 +1 @@ +//! show how old the most recently saved stat is diff --git a/web3_proxy/src/user_queries.rs b/web3_proxy/src/user_queries.rs index f7bc896b..03c6e660 100644 --- a/web3_proxy/src/user_queries.rs +++ b/web3_proxy/src/user_queries.rs @@ -240,6 +240,7 @@ pub async fn query_user_stats<'a>( "total_response_millis", ); + // TODO: make this and q mutable and clean up the code below. no need for more `let q` let condition = Condition::all(); let q = if let StatResponse::Detailed = stat_response_type {