From 2ccd59cf0970d4c2d6e49b1025416a2f7240b888 Mon Sep 17 00:00:00 2001 From: mertcano <35747700+mertcano@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:48:46 +0300 Subject: [PATCH] Fix: Harden startup and API error propagation ### Description This PR addresses unreliable initialization and runtime behavior in the alerter service[cite: 41]. Previously, the service relied on unsafe `unwrap` paths that could cause panics or allow the process to continue running in a falsely healthy state after a failure[cite: 41]. ### Key Changes * **Startup & Error Propagation (`crates/alerter/src/lib.rs`, `crates/alerter/src/main.rs`):** Replaced production `unwrap` calls with proper typed error propagation using `anyhow::Result` and the `?` operator[cite: 41, 49, 50]. The executable now reliably returns initialization or runtime failures back to the operating system, terminating safely rather than continuing as a falsely healthy process[cite: 41, 50]. --- crates/alerter/src/lib.rs | 781 +++++++++++++++++++------------------ crates/alerter/src/main.rs | 10 +- 2 files changed, 396 insertions(+), 395 deletions(-) diff --git a/crates/alerter/src/lib.rs b/crates/alerter/src/lib.rs index fe6d412428..eae58416e8 100644 --- a/crates/alerter/src/lib.rs +++ b/crates/alerter/src/lib.rs @@ -1,205 +1,206 @@ -// This application observes the order book api and tries to determine if the -// solver is down. It does this by checking if no trades have been made recently -// and if so checking if it finds a matchable order according to an external -// price api (0x). If this is the case it alerts. - -use { - alloy::primitives::{Address, U256, address}, - anyhow::{Context, Result}, - clap::Parser, - model::order::{BUY_ETH_ADDRESS, OrderClass, OrderKind, OrderStatus, OrderUid}, - number::serialization::HexOrDecimalU256, - prometheus::IntGauge, - reqwest::Client, - serde_with::serde_as, - std::{ - collections::HashMap, - time::{Duration, Instant}, - }, - url::Url, -}; - -#[serde_as] -#[derive(Debug, serde::Deserialize, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -struct Order { - kind: OrderKind, - buy_token: Address, - #[serde_as(as = "HexOrDecimalU256")] - buy_amount: U256, - sell_token: Address, - #[serde_as(as = "HexOrDecimalU256")] - sell_amount: U256, - uid: OrderUid, - partially_fillable: bool, - #[serde(flatten)] - class: OrderClass, - // Some if the order is fetched from api/v1/orders/{uid} - // None if the order is fetched from api/v1/auction - #[serde(default)] - status: Option, -} - -impl Order { - fn is_liquidity_order(&self) -> bool { - matches!(self.class, OrderClass::Liquidity) - } -} - -struct OrderBookApi { - base: Url, - client: Client, -} - +// This application observes the order book api and tries to determine if the +// solver is down. It does this by checking if no trades have been made recently +// and if so checking if it finds a matchable order according to an external +// price api (0x). If this is the case it alerts. + +use { + alloy::primitives::{Address, U256, address}, + anyhow::{Context, Result}, + clap::Parser, + model::order::{BUY_ETH_ADDRESS, OrderClass, OrderKind, OrderStatus, OrderUid}, + number::serialization::HexOrDecimalU256, + prometheus::IntGauge, + reqwest::Client, + serde_with::serde_as, + std::{ + collections::HashMap, + time::{Duration, Instant}, + }, + url::Url, +}; + +#[serde_as] +#[derive(Debug, serde::Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +struct Order { + kind: OrderKind, + buy_token: Address, + #[serde_as(as = "HexOrDecimalU256")] + buy_amount: U256, + sell_token: Address, + #[serde_as(as = "HexOrDecimalU256")] + sell_amount: U256, + uid: OrderUid, + partially_fillable: bool, + #[serde(flatten)] + class: OrderClass, + // Some if the order is fetched from api/v1/orders/{uid} + // None if the order is fetched from api/v1/auction + #[serde(default)] + status: Option, +} + +impl Order { + fn is_liquidity_order(&self) -> bool { + matches!(self.class, OrderClass::Liquidity) + } +} + +struct OrderBookApi { + base: Url, + client: Client, +} + impl OrderBookApi { - pub fn new(client: Client, base_url: &str) -> Self { - Self { - base: base_url.parse().unwrap(), - client, - } - } - - pub async fn solvable_orders(&self) -> reqwest::Result> { - #[derive(serde::Deserialize)] - struct Auction { - orders: Vec, - } - let url = shared::url::join(&self.base, "api/v1/auction"); - let auction: Auction = self - .client - .get(url) - .send() - .await? - .error_for_status()? - .json() - .await?; - Ok(auction.orders) - } - - pub async fn order(&self, uid: &OrderUid) -> reqwest::Result { - let url = shared::url::join(&self.base, &format!("api/v1/orders/{uid}")); - self.client - .get(url) - .send() - .await? - .error_for_status()? - .json() - .await - } -} - -// Converts the eth placeholder address to weth. Leaves other addresses -// untouched. -fn convert_eth_to_weth(token: Address) -> Address { - const WETH: Address = address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); - if token.as_slice() == BUY_ETH_ADDRESS.as_bytes() { - WETH - } else { - token - } -} - -struct ZeroExApi { - base: Url, - client: Client, - api_key: String, -} - -impl ZeroExApi { - pub fn new(client: Client, api_key: String) -> Self { - Self { - base: "https://api.0x.org".parse().unwrap(), + pub fn new(client: Client, base_url: &str) -> Result { + Ok(Self { + base: base_url.parse().context("invalid order book API URL")?, client, - api_key, - } + }) } - - pub async fn can_be_settled(&self, order: &Order) -> Result { - let mut url = shared::url::join(&self.base, "swap/v1/price"); - - let (amount_name, amount) = match order.kind { - OrderKind::Buy => ("buyAmount", order.buy_amount), - OrderKind::Sell => ("sellAmount", order.sell_amount), - }; - - let buy_token = convert_eth_to_weth(order.buy_token); - url.query_pairs_mut() - .append_pair("sellToken", &format!("{:#x}", order.sell_token)) - .append_pair("buyToken", &format!("{buy_token:#x}")) - .append_pair(amount_name, &amount.to_string()); - - #[serde_as] - #[derive(Debug, serde::Deserialize)] - #[serde(rename_all = "camelCase")] - pub struct Response { - #[serde_as(as = "HexOrDecimalU256")] - pub sell_amount: U256, - #[serde_as(as = "HexOrDecimalU256")] - pub buy_amount: U256, - } - - let response: Response = self - .client - .get(url.clone()) - .header("0x-api-key", self.api_key.clone()) - .send() - .await? - .error_for_status()? - .json() - .await?; - - tracing::debug!(url = url.as_str(), ?response, "0x"); - - let can_settle = - response.sell_amount <= order.sell_amount && response.buy_amount >= order.buy_amount; - if can_settle { - tracing::debug!(%order.uid, "marking order as settleable"); - } - - Ok(can_settle) - } -} - -struct Alerter { - orderbook_api: OrderBookApi, - zeroex_api: ZeroExApi, - config: AlertConfig, - last_observed_trade: Instant, - last_alert: Option, - // order and for how long it has been matchable - open_orders: HashMap)>, - // Expose a prometheus metric so that we can use our Grafana alert infrastructure. - // - // Set to 0 or 1 depending on whether our alert condition is satisfied which is that there - // hasn't been a trade for some time and that there is an order that has been matchable for - // some time. - no_trades_but_matchable_order: IntGauge, - api_get_order_min_interval: Duration, -} - -struct AlertConfig { - // Alert if no trades have been observed for this long. - time_without_trade: Duration, - // Give the solver some time to settle an order after it has become solvable before we alert. - min_order_solvable_time: Duration, - // Do not alert more often than this. - min_alert_interval: Duration, -} - -impl Alerter { + + pub async fn solvable_orders(&self) -> reqwest::Result> { + #[derive(serde::Deserialize)] + struct Auction { + orders: Vec, + } + let url = shared::url::join(&self.base, "api/v1/auction"); + let auction: Auction = self + .client + .get(url) + .send() + .await? + .error_for_status()? + .json() + .await?; + Ok(auction.orders) + } + + pub async fn order(&self, uid: &OrderUid) -> reqwest::Result { + let url = shared::url::join(&self.base, &format!("api/v1/orders/{uid}")); + self.client + .get(url) + .send() + .await? + .error_for_status()? + .json() + .await + } +} + +// Converts the eth placeholder address to weth. Leaves other addresses +// untouched. +fn convert_eth_to_weth(token: Address) -> Address { + const WETH: Address = address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); + if token.as_slice() == BUY_ETH_ADDRESS.as_bytes() { + WETH + } else { + token + } +} + +struct ZeroExApi { + base: Url, + client: Client, + api_key: String, +} + +impl ZeroExApi { + pub fn new(client: Client, api_key: String) -> Self { + Self { + base: "https://api.0x.org".parse().unwrap(), + client, + api_key, + } + } + + pub async fn can_be_settled(&self, order: &Order) -> Result { + let mut url = shared::url::join(&self.base, "swap/v1/price"); + + let (amount_name, amount) = match order.kind { + OrderKind::Buy => ("buyAmount", order.buy_amount), + OrderKind::Sell => ("sellAmount", order.sell_amount), + }; + + let buy_token = convert_eth_to_weth(order.buy_token); + url.query_pairs_mut() + .append_pair("sellToken", &format!("{:#x}", order.sell_token)) + .append_pair("buyToken", &format!("{buy_token:#x}")) + .append_pair(amount_name, &amount.to_string()); + + #[serde_as] + #[derive(Debug, serde::Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct Response { + #[serde_as(as = "HexOrDecimalU256")] + pub sell_amount: U256, + #[serde_as(as = "HexOrDecimalU256")] + pub buy_amount: U256, + } + + let response: Response = self + .client + .get(url.clone()) + .header("0x-api-key", self.api_key.clone()) + .send() + .await? + .error_for_status()? + .json() + .await?; + + tracing::debug!(url = url.as_str(), ?response, "0x"); + + let can_settle = + response.sell_amount <= order.sell_amount && response.buy_amount >= order.buy_amount; + if can_settle { + tracing::debug!(%order.uid, "marking order as settleable"); + } + + Ok(can_settle) + } +} + +struct Alerter { + orderbook_api: OrderBookApi, + zeroex_api: ZeroExApi, + config: AlertConfig, + last_observed_trade: Instant, + last_alert: Option, + // order and for how long it has been matchable + open_orders: HashMap)>, + // Expose a prometheus metric so that we can use our Grafana alert infrastructure. + // + // Set to 0 or 1 depending on whether our alert condition is satisfied which is that there + // hasn't been a trade for some time and that there is an order that has been matchable for + // some time. + no_trades_but_matchable_order: IntGauge, + api_get_order_min_interval: Duration, +} + +struct AlertConfig { + // Alert if no trades have been observed for this long. + time_without_trade: Duration, + // Give the solver some time to settle an order after it has become solvable before we alert. + min_order_solvable_time: Duration, + // Do not alert more often than this. + min_alert_interval: Duration, +} + +impl Alerter { pub fn new( orderbook_api: OrderBookApi, zeroex_api: ZeroExApi, config: AlertConfig, api_get_order_min_interval: Duration, - ) -> Self { + ) -> Result { let registry = observe::metrics::get_registry(); let no_trades_but_matchable_order = - IntGauge::new("no_trades_but_matchable_order", "0 or 1").unwrap(); + IntGauge::new("no_trades_but_matchable_order", "0 or 1") + .context("create no-trades metric")?; registry .register(Box::new(no_trades_but_matchable_order.clone())) - .unwrap(); - Self { + .context("register no-trades metric")?; + Ok(Self { orderbook_api, zeroex_api, config, @@ -208,181 +209,181 @@ impl Alerter { open_orders: HashMap::new(), no_trades_but_matchable_order, api_get_order_min_interval, - } + }) } - - async fn update_open_orders(&mut self) -> Result<()> { - let mut orders = self - .orderbook_api - .solvable_orders() - .await - .context("solvable_orders")? - .into_iter() - .filter(|order| !order.is_liquidity_order() && !order.partially_fillable) - .map(|order| { - let existing_time = self.open_orders.get(&order.uid).and_then(|o| o.1); - (order.uid, (order, existing_time)) - }) - .collect::>(); - - tracing::debug!("found {} open orders", orders.len()); - - std::mem::swap(&mut self.open_orders, &mut orders); - let mut closed_orders: Vec = orders.into_values().map(|(order, _)| order).collect(); - // Keep only orders that were open last update and are not open this update. - closed_orders.retain(|order| !self.open_orders.contains_key(&order.uid)); - // We're trying to find an order that has been filled. Try market orders first - // because they are more likely to be. - closed_orders.sort_unstable_by_key(|order| match order.class { - OrderClass::Market => 0u8, - OrderClass::Limit => 1, - OrderClass::Liquidity => 2, - }); - for order in closed_orders { - let uid = &order.uid; - tracing::debug!(order =% uid, "found closed order"); - let start = Instant::now(); + + async fn update_open_orders(&mut self) -> Result<()> { + let mut orders = self + .orderbook_api + .solvable_orders() + .await + .context("solvable_orders")? + .into_iter() + .filter(|order| !order.is_liquidity_order() && !order.partially_fillable) + .map(|order| { + let existing_time = self.open_orders.get(&order.uid).and_then(|o| o.1); + (order.uid, (order, existing_time)) + }) + .collect::>(); + + tracing::debug!("found {} open orders", orders.len()); + + std::mem::swap(&mut self.open_orders, &mut orders); + let mut closed_orders: Vec = orders.into_values().map(|(order, _)| order).collect(); + // Keep only orders that were open last update and are not open this update. + closed_orders.retain(|order| !self.open_orders.contains_key(&order.uid)); + // We're trying to find an order that has been filled. Try market orders first + // because they are more likely to be. + closed_orders.sort_unstable_by_key(|order| match order.class { + OrderClass::Market => 0u8, + OrderClass::Limit => 1, + OrderClass::Liquidity => 2, + }); + for order in closed_orders { + let uid = &order.uid; + tracing::debug!(order =% uid, "found closed order"); + let start = Instant::now(); let api_order = self.orderbook_api.order(uid).await.context("get order")?; - if api_order.status.unwrap() == OrderStatus::Fulfilled { - tracing::debug!( - "updating last observed trade because order {} was fulfilled", - uid - ); - self.last_observed_trade = Instant::now(); - break; - } - tokio::time::sleep_until((start + self.api_get_order_min_interval).into()).await; - } - tracing::debug!("found no fulfilled orders"); - Ok(()) - } - - pub async fn update(&mut self) -> Result<()> { - self.update_open_orders().await?; - if self.last_observed_trade.elapsed() <= self.config.time_without_trade { - self.no_trades_but_matchable_order.set(0); - // Delete all matchable timestamps. - // - // If we didn't do this what could happen is that first we mark an order as - // matchable at t0. Then a trade happens so we skip the matchable - // update loop below because if there was a recent trade we don't - // want to alert anyway. Then no trade happens for long enough that - // we want to alert and the order is again matchable. In this case - // we would alert immediately even though it could be the case that the - // order wasn't matchable and just now became matchable again. We would wrongly - // assume it has been matchable since t0 but we did not check this - // between now and then. - for (_, instant) in self.open_orders.values_mut() { - *instant = None; - } - return Ok(()); - } - - for (order, last_solvable) in self.open_orders.values_mut() { - let can_be_settled = self - .zeroex_api - .can_be_settled(order) - .await - .context("can_be_settled")?; - let now = Instant::now(); - if can_be_settled { - let solvable_since = *last_solvable.get_or_insert(now); - if now.duration_since(solvable_since) > self.config.min_order_solvable_time { - let should_alert = match self.last_alert { - None => true, - Some(instant) => instant.elapsed() >= self.config.min_alert_interval, - }; - if should_alert { - self.last_alert = Some(now); - self.config.alert(&order.uid); - } - self.no_trades_but_matchable_order.set(1); - } - return Ok(()); - } else { - *last_solvable = None; - } - } - - self.no_trades_but_matchable_order.set(0); - Ok(()) - } -} - -impl AlertConfig { - fn alert(&self, order_uid: &OrderUid) { - tracing::error!( - "No orders have been settled in the last {} seconds even though order {} is solvable \ - and has a price that allows it to be settled according to 0x.", - self.time_without_trade.as_secs(), - order_uid, - ); - } -} - -#[derive(Debug, Parser)] -struct Arguments { - /// Alerter update interval. - #[clap( - long, - env, - default_value = "30s", - value_parser = humantime::parse_duration, - )] - update_interval: Duration, - - /// Minimum time without a trade before alerting. - #[clap( - long, - env, - default_value = "10m", - value_parser = humantime::parse_duration, - )] - time_without_trade: Duration, - - /// Minimum time an order must have been matchable for before alerting. - #[clap( - long, - env, - default_value = "3m", - value_parser = humantime::parse_duration, - )] - min_order_age: Duration, - - /// Do not repeat the alert more often than this. - #[clap( - long, - env, - default_value = "30m", - value_parser = humantime::parse_duration, - )] - min_alert_interval: Duration, - - /// How many errors in the update loop (fetching solvable orders or querying - /// 0x) in a row must happen before we alert about them. - #[clap(long, env, default_value = "5")] - errors_in_a_row_before_alert: u32, - - #[clap(long, env, default_value = "https://api.cow.fi/mainnet/")] - orderbook_api: String, - - #[clap(long, env, default_value = "9588")] - metrics_port: u16, - - /// Minimum time between get order requests to the api. Without this the api - /// can rate limit us. - #[clap(long, env, default_value = "200ms", value_parser = humantime::parse_duration)] - api_get_order_min_interval: Duration, - - #[clap(long, env)] - zero_ex_api_key: String, - - /// Whether to use JSON format for the logs. - #[clap(long, env, default_value = "false")] - pub use_json_logs: bool, -} - -pub async fn start(args: impl Iterator) { + if matches!(api_order.status, Some(OrderStatus::Fulfilled)) { + tracing::debug!( + "updating last observed trade because order {} was fulfilled", + uid + ); + self.last_observed_trade = Instant::now(); + break; + } + tokio::time::sleep_until((start + self.api_get_order_min_interval).into()).await; + } + tracing::debug!("found no fulfilled orders"); + Ok(()) + } + + pub async fn update(&mut self) -> Result<()> { + self.update_open_orders().await?; + if self.last_observed_trade.elapsed() <= self.config.time_without_trade { + self.no_trades_but_matchable_order.set(0); + // Delete all matchable timestamps. + // + // If we didn't do this what could happen is that first we mark an order as + // matchable at t0. Then a trade happens so we skip the matchable + // update loop below because if there was a recent trade we don't + // want to alert anyway. Then no trade happens for long enough that + // we want to alert and the order is again matchable. In this case + // we would alert immediately even though it could be the case that the + // order wasn't matchable and just now became matchable again. We would wrongly + // assume it has been matchable since t0 but we did not check this + // between now and then. + for (_, instant) in self.open_orders.values_mut() { + *instant = None; + } + return Ok(()); + } + + for (order, last_solvable) in self.open_orders.values_mut() { + let can_be_settled = self + .zeroex_api + .can_be_settled(order) + .await + .context("can_be_settled")?; + let now = Instant::now(); + if can_be_settled { + let solvable_since = *last_solvable.get_or_insert(now); + if now.duration_since(solvable_since) > self.config.min_order_solvable_time { + let should_alert = match self.last_alert { + None => true, + Some(instant) => instant.elapsed() >= self.config.min_alert_interval, + }; + if should_alert { + self.last_alert = Some(now); + self.config.alert(&order.uid); + } + self.no_trades_but_matchable_order.set(1); + } + return Ok(()); + } else { + *last_solvable = None; + } + } + + self.no_trades_but_matchable_order.set(0); + Ok(()) + } +} + +impl AlertConfig { + fn alert(&self, order_uid: &OrderUid) { + tracing::error!( + "No orders have been settled in the last {} seconds even though order {} is solvable \ + and has a price that allows it to be settled according to 0x.", + self.time_without_trade.as_secs(), + order_uid, + ); + } +} + +#[derive(Debug, Parser)] +struct Arguments { + /// Alerter update interval. + #[clap( + long, + env, + default_value = "30s", + value_parser = humantime::parse_duration, + )] + update_interval: Duration, + + /// Minimum time without a trade before alerting. + #[clap( + long, + env, + default_value = "10m", + value_parser = humantime::parse_duration, + )] + time_without_trade: Duration, + + /// Minimum time an order must have been matchable for before alerting. + #[clap( + long, + env, + default_value = "3m", + value_parser = humantime::parse_duration, + )] + min_order_age: Duration, + + /// Do not repeat the alert more often than this. + #[clap( + long, + env, + default_value = "30m", + value_parser = humantime::parse_duration, + )] + min_alert_interval: Duration, + + /// How many errors in the update loop (fetching solvable orders or querying + /// 0x) in a row must happen before we alert about them. + #[clap(long, env, default_value = "5")] + errors_in_a_row_before_alert: u32, + + #[clap(long, env, default_value = "https://api.cow.fi/mainnet/")] + orderbook_api: String, + + #[clap(long, env, default_value = "9588")] + metrics_port: u16, + + /// Minimum time between get order requests to the api. Without this the api + /// can rate limit us. + #[clap(long, env, default_value = "200ms", value_parser = humantime::parse_duration)] + api_get_order_min_interval: Duration, + + #[clap(long, env)] + zero_ex_api_key: String, + + /// Whether to use JSON format for the logs. + #[clap(long, env, default_value = "false")] + pub use_json_logs: bool, +} + +pub async fn start(args: impl Iterator) -> Result<()> { let args = Arguments::parse_from(args); let obs_config = observe::Config::new( "alerter=debug", @@ -394,42 +395,42 @@ pub async fn start(args: impl Iterator) { observe::panic_hook::install(); observe::metrics::setup_registry(Some("gp_v2_alerter".to_string()), None); tracing::info!("running alerter with {:#?}", args); - run(args).await; + run(args).await } - -async fn run(args: Arguments) { - let filter = observe::metrics::handle_metrics(); - tokio::task::spawn(warp::serve(filter).bind(([0, 0, 0, 0], args.metrics_port))); - + +async fn run(args: Arguments) -> Result<()> { + let filter = observe::metrics::handle_metrics(); + tokio::task::spawn(warp::serve(filter).bind(([0, 0, 0, 0], args.metrics_port))); + let client = Client::builder() .timeout(Duration::from_secs(10)) .build() - .unwrap(); + .context("build HTTP client")?; let mut alerter = Alerter::new( - OrderBookApi::new(client.clone(), &args.orderbook_api), + OrderBookApi::new(client.clone(), &args.orderbook_api)?, ZeroExApi::new(client, args.zero_ex_api_key), AlertConfig { - time_without_trade: args.time_without_trade, + time_without_trade: args.time_without_trade, min_order_solvable_time: args.min_order_age, min_alert_interval: args.min_alert_interval, }, args.api_get_order_min_interval, - ); - - let mut errors_in_a_row = 0; - loop { - match alerter.update().await { - Ok(()) => errors_in_a_row = 0, - Err(err) if errors_in_a_row < args.errors_in_a_row_before_alert => { - errors_in_a_row += 1; - tracing::warn!(?err, "alerter update error"); - } - Err(err) => { - errors_in_a_row = 0; - tracing::error!(?err, "alerter update error"); - } - } - tokio::time::sleep(args.update_interval).await; - } -} + )?; + + let mut errors_in_a_row = 0; + loop { + match alerter.update().await { + Ok(()) => errors_in_a_row = 0, + Err(err) if errors_in_a_row < args.errors_in_a_row_before_alert => { + errors_in_a_row += 1; + tracing::warn!(?err, "alerter update error"); + } + Err(err) => { + errors_in_a_row = 0; + tracing::error!(?err, "alerter update error"); + } + } + tokio::time::sleep(args.update_interval).await; + } +} diff --git a/crates/alerter/src/main.rs b/crates/alerter/src/main.rs index 73a70ddf51..4df9fec40f 100644 --- a/crates/alerter/src/main.rs +++ b/crates/alerter/src/main.rs @@ -1,7 +1,7 @@ -#[global_allocator] -static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; - +#[global_allocator] +static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; + #[tokio::main] -async fn main() { - alerter::start(std::env::args()).await; +async fn main() -> anyhow::Result<()> { + alerter::start(std::env::args()).await }