Skip to content

Commit 561765f

Browse files
avi-starkwareclaude
andcommitted
starknet_transaction_prover: HTTP request count + latency + in-flight metrics
Adds `HttpMetricsLayer` placed between the monitoring endpoints and the rest of the stack so probes/scrapes don't distort the request-latency distribution. Records request count by bounded method/status labels, end-to-end latency, and in-flight gauge. Also adds a shared test recorder helper that installs the global Prometheus recorder once across the test binary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a61deca commit 561765f

5 files changed

Lines changed: 258 additions & 0 deletions

File tree

crates/starknet_transaction_prover/src/server.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ pub const OHTTP_JSONRPSEE_BODY_BUILDER: fn(Full<Bytes>) -> HttpBody = HttpBody::
4343
/// - `RequestLogLayer` is outermost so the latency it measures covers every other layer.
4444
/// - `HealthLayer` (and `MetricsLayer` when configured) sit inside it so `/health` probes and
4545
/// `/metrics` scrapes short-circuit before CORS/OHTTP.
46+
/// - `HttpMetricsLayer` records per-request latency; it sits below `HealthLayer`/`MetricsLayer` so
47+
/// the probe and scrape traffic they short-circuit is excluded from the distribution.
4648
/// - `OhttpLayer` must sit OUTSIDE `CompressionLayer` so compression applies to the inner JSON-RPC
4749
/// response (the client's inner `Accept-Encoding` travels through BHTTP into jsonrpsee) rather
4850
/// than to the OHTTP ciphertext envelope. `MapRequestBodyLayer`/`MapResponseBodyLayer` keep
@@ -56,6 +58,7 @@ macro_rules! prover_http_middleware {
5658
.layer(RequestLogLayer)
5759
.layer(HealthLayer)
5860
.option_layer($metrics_layer)
61+
.layer(HttpMetricsLayer)
5962
.option_layer($cors_layer)
6063
.layer(MapRequestBodyLayer::new(HttpBody::new))
6164
.option_layer($ohttp_layer)
@@ -69,6 +72,7 @@ pub mod config;
6972
pub mod cors;
7073
pub mod errors;
7174
pub mod health;
75+
pub mod http_metrics;
7276
pub mod log_redact;
7377
pub mod metrics;
7478
#[cfg(test)]
@@ -83,6 +87,7 @@ pub mod test_recorder;
8387
pub mod tls;
8488

8589
pub use health::{HealthLayer, HEALTH_PATH};
90+
pub use http_metrics::HttpMetricsLayer;
8691
pub use metrics::{MetricsLayer, METRICS_PATH};
8792
pub use request_log::{RequestLogLayer, REQUEST_ID_HEADER};
8893
pub use request_span::RequestSpanLayer;
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
//! tower middleware that records HTTP-level Prometheus metrics:
2+
//! request count, latency histogram, and an RAII-guarded in-flight gauge.
3+
//! Sits inside (below) `HealthLayer`/`MetricsLayer` so the `/health` and
4+
//! `/metrics` probes they short-circuit never reach this layer and don't
5+
//! distort the latency distribution. Label cardinality is bounded by
6+
//! `method_label` and the HTTP status code enumeration.
7+
8+
use std::task::{Context, Poll};
9+
use std::time::Instant;
10+
11+
use http::{Method, Request, Response, StatusCode};
12+
use jsonrpsee::server::HttpBody;
13+
use tower::{Layer, Service};
14+
15+
#[cfg(test)]
16+
#[path = "http_metrics_test.rs"]
17+
mod http_metrics_test;
18+
19+
/// Metric name constants.
20+
pub mod names {
21+
/// Counter of HTTP requests by method + status code.
22+
pub const REQUESTS_TOTAL: &str = "prover_http_requests_total";
23+
/// Histogram of end-to-end HTTP request latency by method.
24+
pub const REQUEST_DURATION_SECONDS: &str = "prover_http_request_duration_seconds";
25+
/// Gauge of in-flight HTTP requests.
26+
pub const IN_FLIGHT_REQUESTS: &str = "prover_http_inflight_requests";
27+
}
28+
29+
/// Pre-registers the three HTTP metrics so they appear in /metrics even
30+
/// before the first request — dashboards relying on `rate(...) > 0` need
31+
/// the series to exist. Note we deliberately do *not* pre-`record` the
32+
/// histogram: that would inject a phantom 0-second observation that
33+
/// distorts every quantile.
34+
pub fn preregister_http_metrics() {
35+
// Source the pre-registered label values from the same helpers the live path uses, so the
36+
// pre-registered series can't drift from the emitted vocabulary.
37+
metrics::counter!(
38+
names::REQUESTS_TOTAL,
39+
"method" => method_label(&Method::POST),
40+
"status" => status_label(StatusCode::OK),
41+
)
42+
.increment(0);
43+
metrics::describe_histogram!(
44+
names::REQUEST_DURATION_SECONDS,
45+
"HTTP request latency in seconds, by method",
46+
);
47+
metrics::gauge!(names::IN_FLIGHT_REQUESTS).set(0.0);
48+
}
49+
50+
#[derive(Clone, Copy)]
51+
pub struct HttpMetricsLayer;
52+
53+
impl<S> Layer<S> for HttpMetricsLayer {
54+
type Service = HttpMetricsService<S>;
55+
56+
fn layer(&self, inner: S) -> Self::Service {
57+
HttpMetricsService { inner }
58+
}
59+
}
60+
61+
#[derive(Clone)]
62+
pub struct HttpMetricsService<S> {
63+
inner: S,
64+
}
65+
66+
impl<S, ReqB> Service<Request<ReqB>> for HttpMetricsService<S>
67+
where
68+
S: Service<Request<ReqB>, Response = Response<HttpBody>>,
69+
S::Future: Send + 'static,
70+
S::Error: Send + 'static,
71+
{
72+
type Response = Response<HttpBody>;
73+
type Error = S::Error;
74+
type Future = std::pin::Pin<
75+
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
76+
>;
77+
78+
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
79+
self.inner.poll_ready(cx)
80+
}
81+
82+
fn call(&mut self, request: Request<ReqB>) -> Self::Future {
83+
let method = method_label(request.method());
84+
let start = Instant::now();
85+
let future = self.inner.call(request);
86+
87+
Box::pin(async move {
88+
metrics::gauge!(names::IN_FLIGHT_REQUESTS).increment(1.0);
89+
let _in_flight_guard = InFlightGuard;
90+
let result = future.await;
91+
let duration_seconds = start.elapsed().as_secs_f64();
92+
let status = match &result {
93+
Ok(response) => status_label(response.status()),
94+
// Sentinel for "tower stack failure, no HTTP response
95+
// produced" so dashboards can filter it out from real codes.
96+
Err(_) => "error",
97+
};
98+
metrics::histogram!(names::REQUEST_DURATION_SECONDS, "method" => method)
99+
.record(duration_seconds);
100+
metrics::counter!(
101+
names::REQUESTS_TOTAL,
102+
"method" => method,
103+
"status" => status,
104+
)
105+
.increment(1);
106+
result
107+
})
108+
}
109+
}
110+
111+
/// Collapses HTTP statuses to a 6-value enum so a malformed response or
112+
/// future framework version that emits exotic codes can't blow up
113+
/// Prometheus series cardinality.
114+
fn status_label(status: StatusCode) -> &'static str {
115+
match status.as_u16() {
116+
100..=199 => "1xx",
117+
200..=299 => "2xx",
118+
300..=399 => "3xx",
119+
400..=499 => "4xx",
120+
500..=599 => "5xx",
121+
_ => "other",
122+
}
123+
}
124+
125+
/// Collapses HTTP methods into a small bounded set of label values so a
126+
/// malformed request (or future hyper version that admits new tokens)
127+
/// can't grow the Prometheus series cardinality unboundedly.
128+
fn method_label(method: &Method) -> &'static str {
129+
match *method {
130+
Method::GET => "GET",
131+
Method::POST => "POST",
132+
Method::PUT => "PUT",
133+
Method::DELETE => "DELETE",
134+
Method::HEAD => "HEAD",
135+
Method::OPTIONS => "OPTIONS",
136+
Method::PATCH => "PATCH",
137+
_ => "other",
138+
}
139+
}
140+
141+
/// Decrements the in-flight gauge when dropped. Using a guard rather than
142+
/// an explicit decrement after `future.await` covers panic + cancellation
143+
/// paths so the gauge can't leak upward without coming back down.
144+
struct InFlightGuard;
145+
146+
impl Drop for InFlightGuard {
147+
fn drop(&mut self) {
148+
metrics::gauge!(names::IN_FLIGHT_REQUESTS).decrement(1.0);
149+
}
150+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//! Unit tests for [`HttpMetricsLayer`].
2+
//!
3+
//! All HTTP-metric tests live in this single `#[tokio::test]` because the
4+
//! Prometheus recorder is process-global: parallel tests sharing the same
5+
//! recorder would race on counter values. We run a sequence of requests
6+
//! and assert deltas between them.
7+
8+
use bytes::Bytes;
9+
use http::{Method, Request, Response, StatusCode};
10+
use http_body_util::Full;
11+
use jsonrpsee::server::HttpBody;
12+
use tower::{Layer, ServiceExt};
13+
14+
use crate::server::http_metrics::{names, HttpMetricsLayer};
15+
use crate::server::test_recorder::shared_handle;
16+
17+
fn ok_service() -> impl tower::Service<
18+
Request<HttpBody>,
19+
Response = Response<HttpBody>,
20+
Error = std::convert::Infallible,
21+
Future = futures::future::Ready<Result<Response<HttpBody>, std::convert::Infallible>>,
22+
> + Clone {
23+
tower::service_fn(|_req: Request<HttpBody>| {
24+
let response = Response::builder()
25+
.status(StatusCode::OK)
26+
.body(HttpBody::new(Full::new(Bytes::new())))
27+
.expect("static body is infallible");
28+
futures::future::ready(Ok::<_, std::convert::Infallible>(response))
29+
})
30+
}
31+
32+
fn build_request(method: Method) -> Request<HttpBody> {
33+
Request::builder()
34+
.method(method)
35+
.uri("/")
36+
.body(HttpBody::new(Full::new(Bytes::new())))
37+
.expect("static body is infallible")
38+
}
39+
40+
#[tokio::test]
41+
async fn records_counter_histogram_and_returns_inflight_to_zero() {
42+
let handle = shared_handle();
43+
let svc = HttpMetricsLayer.layer(ok_service());
44+
45+
// Capture counter / histogram baselines before this test runs so we
46+
// can assert deltas — the recorder is shared across the test binary
47+
// so other tests may have moved the absolute values.
48+
let before = parse_counter_and_histogram(&handle.render());
49+
50+
// Issue three POSTs sequentially. After each await the in-flight gauge
51+
// must drop back to zero (the guard runs on future completion).
52+
for _ in 0..3 {
53+
let response = svc.clone().oneshot(build_request(Method::POST)).await.unwrap();
54+
assert_eq!(response.status(), StatusCode::OK);
55+
}
56+
57+
let scrape = handle.render();
58+
let after = parse_counter_and_histogram(&scrape);
59+
assert_eq!(after.counter - before.counter, 3.0, "counter delta");
60+
assert_eq!(after.histogram_count - before.histogram_count, 3.0, "histogram delta");
61+
62+
// Gauge returned to zero — guard ran for every request.
63+
let gauge_line = scrape
64+
.lines()
65+
.find(|line| line.starts_with(names::IN_FLIGHT_REQUESTS) && !line.starts_with("# "))
66+
.unwrap_or_else(|| panic!("missing in-flight gauge in scrape:\n{scrape}"));
67+
let gauge_value: f64 =
68+
gauge_line.rsplit_once(' ').and_then(|(_, value)| value.parse().ok()).expect("gauge parse");
69+
assert_eq!(gauge_value, 0.0);
70+
}
71+
72+
struct Snapshot {
73+
counter: f64,
74+
histogram_count: f64,
75+
}
76+
77+
fn parse_counter_and_histogram(scrape: &str) -> Snapshot {
78+
let counter = scrape
79+
.lines()
80+
.find(|line| {
81+
line.starts_with(names::REQUESTS_TOTAL)
82+
&& line.contains("method=\"POST\"")
83+
&& line.contains("status=\"2xx\"")
84+
&& !line.starts_with("# ")
85+
})
86+
.and_then(|line| line.rsplit_once(' ').and_then(|(_, value)| value.parse().ok()))
87+
.unwrap_or(0.0);
88+
let histogram_count = scrape
89+
.lines()
90+
.find(|line| {
91+
line.starts_with(&format!("{}_count", names::REQUEST_DURATION_SECONDS))
92+
&& line.contains("method=\"POST\"")
93+
&& !line.starts_with("# ")
94+
})
95+
.and_then(|line| line.rsplit_once(' ').and_then(|(_, value)| value.parse().ok()))
96+
.unwrap_or(0.0);
97+
Snapshot { counter, histogram_count }
98+
}

crates/starknet_transaction_prover/src/server/metrics.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ pub fn install_exporter(version: &str, git_sha: &str) -> anyhow::Result<Promethe
7070
"git_sha" => git_sha.to_string(),
7171
)
7272
.set(1.0);
73+
// Pre-register counters/gauges at zero so they show up in scrapes
74+
// before the first request — dashboards relying on `rate(...) > 0`
75+
// need the series to exist.
76+
super::http_metrics::preregister_http_metrics();
7377
Ok(handle)
7478
}
7579

crates/starknet_transaction_prover/src/server/tls.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use tracing::warn;
2929

3030
use crate::server::{
3131
HealthLayer,
32+
HttpMetricsLayer,
3233
MetricsLayer,
3334
OhttpJsonrpseeLayer,
3435
RequestLogLayer,

0 commit comments

Comments
 (0)