From 5313b89000ec47ac338fa8a5d9407fc8bfcd4f1b Mon Sep 17 00:00:00 2001 From: Aryan Godara <65490434+AryanGodara@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:54:24 +0530 Subject: [PATCH] Add cancellation-safe quote request metrics --- crates/orderbook/src/api/post_quote.rs | 155 ++++++++++++++++++++++++- crates/shared/src/order_quoting.rs | 27 +++++ 2 files changed, 177 insertions(+), 5 deletions(-) diff --git a/crates/orderbook/src/api/post_quote.rs b/crates/orderbook/src/api/post_quote.rs index 4981e6cc64..e188da0bff 100644 --- a/crates/orderbook/src/api/post_quote.rs +++ b/crates/orderbook/src/api/post_quote.rs @@ -9,7 +9,7 @@ use { extract::State, response::{IntoResponse, Response}, }, - model::quote::OrderQuoteRequest, + model::quote::{OrderQuoteRequest, PriceQuality}, reqwest::StatusCode, shared::order_quoting::CalculateQuoteError, std::sync::Arc, @@ -19,15 +19,82 @@ pub async fn post_quote_handler( State(state): State>, Json(request): Json, ) -> Response { - state - .quotes - .calculate_quote(&request) - .await + // Record the request synchronously, before the first `.await`, so requests + // whose future is dropped (client disconnects) are still counted. The guard + // defaults to `cancelled` and is overwritten only once a response exists, so + // a dropped future records a cancellation when it is dropped. + let mut guard = QuoteRequestGuard::new(price_quality_label(&request.price_quality)); + + let result = state.quotes.calculate_quote(&request).await; + guard.finish(if result.is_ok() { "success" } else { "error" }); + + result .map(Json) .inspect_err(|err| tracing::warn!(%err, ?request, "post_quote error")) .into_response() } +#[derive(prometheus_metric_storage::MetricStorage, Clone, Debug)] +#[metric(subsystem = "quote")] +struct QuoteMetrics { + /// /quote requests received, incremented at handler entry before any await + /// so client-cancelled requests are still counted. + #[metric(labels("price_quality"))] + requests_started: prometheus::IntCounterVec, + + /// /quote requests that finished, by `result` (`success`, `error`, or + /// `cancelled`). `cancelled` means the client dropped the connection before + /// a response was produced. Invariant: started == sum(finished). + #[metric(labels("price_quality", "result"))] + requests_finished: prometheus::IntCounterVec, +} + +/// Ties a `requests_started` increment at handler entry to a +/// `requests_finished` increment when the handler future completes or is +/// dropped. Defaults the outcome to `cancelled`; [`QuoteRequestGuard::finish`] +/// records the real outcome once a response is available. +struct QuoteRequestGuard { + metrics: &'static QuoteMetrics, + price_quality: &'static str, + result: &'static str, +} + +impl QuoteRequestGuard { + fn new(price_quality: &'static str) -> Self { + let metrics = QuoteMetrics::instance(observe::metrics::get_storage_registry()).unwrap(); + metrics + .requests_started + .with_label_values(&[price_quality]) + .inc(); + Self { + metrics, + price_quality, + result: "cancelled", + } + } + + fn finish(&mut self, result: &'static str) { + self.result = result; + } +} + +impl Drop for QuoteRequestGuard { + fn drop(&mut self) { + self.metrics + .requests_finished + .with_label_values(&[self.price_quality, self.result]) + .inc(); + } +} + +fn price_quality_label(price_quality: &PriceQuality) -> &'static str { + match price_quality { + PriceQuality::Fast => "fast", + PriceQuality::Optimal => "optimal", + PriceQuality::Verified => "verified", + } +} + impl IntoResponse for OrderQuoteError { fn into_response(self) -> Response { match self { @@ -306,4 +373,82 @@ mod tests { // There are many other FeeAndQuoteErrors, but writing a test for each // would follow the same pattern as this. } + + // Unique per-test labels keep the shared global metrics registry from + // leaking counts between tests. + #[test] + fn guard_records_cancelled_when_dropped_without_finish() { + let metrics = QuoteMetrics::instance(observe::metrics::get_storage_registry()).unwrap(); + let started = metrics + .requests_started + .with_label_values(&["dropped"]) + .get(); + let cancelled = metrics + .requests_finished + .with_label_values(&["dropped", "cancelled"]) + .get(); + + // Dropping the guard without `finish` models a client-cancelled request. + drop(QuoteRequestGuard::new("dropped")); + + assert_eq!( + metrics + .requests_started + .with_label_values(&["dropped"]) + .get(), + started + 1 + ); + assert_eq!( + metrics + .requests_finished + .with_label_values(&["dropped", "cancelled"]) + .get(), + cancelled + 1 + ); + } + + #[test] + fn guard_records_outcome_after_finish() { + let metrics = QuoteMetrics::instance(observe::metrics::get_storage_registry()).unwrap(); + let started = metrics + .requests_started + .with_label_values(&["finished"]) + .get(); + let success = metrics + .requests_finished + .with_label_values(&["finished", "success"]) + .get(); + let cancelled = metrics + .requests_finished + .with_label_values(&["finished", "cancelled"]) + .get(); + + let mut guard = QuoteRequestGuard::new("finished"); + guard.finish("success"); + drop(guard); + + // started == sum(finished): exactly one started and one success, no + // cancellation recorded once `finish` ran. + assert_eq!( + metrics + .requests_started + .with_label_values(&["finished"]) + .get(), + started + 1 + ); + assert_eq!( + metrics + .requests_finished + .with_label_values(&["finished", "success"]) + .get(), + success + 1 + ); + assert_eq!( + metrics + .requests_finished + .with_label_values(&["finished", "cancelled"]) + .get(), + cancelled + ); + } } diff --git a/crates/shared/src/order_quoting.rs b/crates/shared/src/order_quoting.rs index ec080cbf77..4a5d091c3d 100644 --- a/crates/shared/src/order_quoting.rs +++ b/crates/shared/src/order_quoting.rs @@ -413,6 +413,19 @@ pub struct OrderQuoter { streaming_price_estimator: Option>, } +/// Metrics for the quote computation path. Registered under the binary's metric +/// prefix, yielding `gp_v2_api_quote_downstream_requests` in the orderbook. +#[derive(prometheus_metric_storage::MetricStorage)] +#[metric(subsystem = "quote")] +struct Metrics { + /// Downstream estimation requests dispatched while computing quotes, by + /// `kind` (`trade` or `native`). Counted above the estimator caches, so it + /// reflects quotes reaching the estimation layer rather than raw external + /// calls (see `price_estimates` for per-estimator counts). + #[metric(labels("kind"))] + downstream_requests: prometheus::IntCounterVec, +} + impl OrderQuoter { pub fn new( price_estimator: Arc, @@ -465,6 +478,20 @@ impl OrderQuoter { let trade_query = Arc::new(parameters.to_price_query(self.default_quote_timeout, self.max_quote_timeout)); + + // One trade estimate plus two native price lookups (sell + buy) are + // dispatched below. Count them before the fan-out so the metric captures + // quotes that reached estimation. + let metrics = Metrics::instance(observe::metrics::get_storage_registry()).unwrap(); + metrics + .downstream_requests + .with_label_values(&["trade"]) + .inc(); + metrics + .downstream_requests + .with_label_values(&["native"]) + .inc_by(2); + let (effective_gas_price, trade_estimate, sell_token_price, _) = futures::try_join!( self.gas_estimator .effective_gas_price()