Skip to content

Commit 7b2b116

Browse files
authored
feat(pbs): count the ws bid stream under its own metrics endpoint (#499)
1 parent 3b0b051 commit 7b2b116

5 files changed

Lines changed: 125 additions & 17 deletions

File tree

‎crates/pbs/src/constants.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
use reqwest::StatusCode;
2+
13
pub const STATUS_ENDPOINT_TAG: &str = "status";
24
pub const REGISTER_VALIDATOR_ENDPOINT_TAG: &str = "register_validator";
35
pub const SUBMIT_BLINDED_BLOCK_ENDPOINT_TAG: &str = "submit_blinded_block";
46
pub const GET_HEADER_ENDPOINT_TAG: &str = "get_header";
57
pub const RELOAD_ENDPOINT_TAG: &str = "reload";
8+
pub const GET_HEADER_STREAM_ENDPOINT_TAG: &str = "get_header_stream";
69

710
/// For metrics recorded when a request times out
811
pub const TIMEOUT_ERROR_CODE: u16 = 555;
@@ -12,6 +15,16 @@ pub const TIMEOUT_ERROR_CODE_STR: &str = "555";
1215
/// deadline expiring: refused, dns, tls, or a stream that broke mid-window
1316
pub const TRANSPORT_ERROR_CODE: u16 = 556;
1417

18+
pub const TIMEOUT_ERROR_STATUS: StatusCode = synthetic_status(TIMEOUT_ERROR_CODE);
19+
pub const TRANSPORT_ERROR_STATUS: StatusCode = synthetic_status(TRANSPORT_ERROR_CODE);
20+
21+
const fn synthetic_status(code: u16) -> StatusCode {
22+
match StatusCode::from_u16(code) {
23+
Ok(status) => status,
24+
Err(_) => panic!("synthetic status codes are within the valid range"),
25+
}
26+
}
27+
1528
/// 20 MiB to cover edge cases for heavy blocks and also add a bit of slack for
1629
/// any Ethereum upgrades in the near future
1730
pub const MAX_SIZE_SUBMIT_BLOCK_RESPONSE: usize = 20 * 1024 * 1024;

‎crates/pbs/src/metrics.rs‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,50 @@ lazy_static! {
5252
.unwrap();
5353

5454

55+
// THE WEBSOCKET BID STREAM
56+
// Outcome and time-to-first-bid ride RELAY_STATUS_CODE / RELAY_LATENCY
57+
// under `get_header_stream`
58+
59+
/// Websocket handshake latency by relay
60+
pub static ref RELAY_STREAM_CONNECT_LATENCY: HistogramVec = register_histogram_vec_with_registry!(
61+
"relay_stream_connect_latency",
62+
"Websocket handshake latency by relay",
63+
&["relay_id"],
64+
vec![0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 1.0],
65+
PBS_METRICS_REGISTRY
66+
)
67+
.unwrap();
68+
69+
/// Bid updates received per stream window by relay
70+
pub static ref RELAY_STREAM_UPDATES: HistogramVec = register_histogram_vec_with_registry!(
71+
"relay_stream_updates",
72+
"Bid updates received per stream window by relay",
73+
&["relay_id"],
74+
vec![0.0, 1.0, 2.0, 3.0, 5.0, 10.0, 20.0, 50.0, 100.0],
75+
PBS_METRICS_REGISTRY
76+
)
77+
.unwrap();
78+
79+
/// Websocket frames that could not be parsed as a bid, by relay
80+
pub static ref RELAY_STREAM_INVALID_FRAMES: IntCounterVec = register_int_counter_vec_with_registry!(
81+
"relay_stream_invalid_frames_total",
82+
"Websocket frames that could not be parsed as a bid, by relay",
83+
&["relay_id"],
84+
PBS_METRICS_REGISTRY
85+
)
86+
.unwrap();
87+
88+
/// Stream attempts that fell back to HTTP, by relay
89+
// Only a handshake failure with bid window left retries; one at the
90+
// deadline shows on the status series alone
91+
pub static ref RELAY_STREAM_FALLBACK: IntCounterVec = register_int_counter_vec_with_registry!(
92+
"relay_stream_fallback_total",
93+
"get_header stream attempts that fell back to HTTP, by relay",
94+
&["relay_id"],
95+
PBS_METRICS_REGISTRY
96+
)
97+
.unwrap();
98+
5599
// TO BEACON NODE
56100
/// Status code returned to beacon node by endpoint
57101
pub static ref BEACON_NODE_STATUS: IntCounterVec = register_int_counter_vec_with_registry!(

‎crates/pbs/src/mev_boost/get_header.rs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ use crate::{
4242
GET_HEADER_ENDPOINT_TAG, MAX_SIZE_GET_HEADER_RESPONSE, TIMEOUT_ERROR_CODE,
4343
TIMEOUT_ERROR_CODE_STR,
4444
},
45-
metrics::{RELAY_HEADER_VALUE, RELAY_LAST_SLOT, RELAY_LATENCY, RELAY_STATUS_CODE},
45+
metrics::{
46+
RELAY_HEADER_VALUE, RELAY_LAST_SLOT, RELAY_LATENCY, RELAY_STATUS_CODE,
47+
RELAY_STREAM_FALLBACK,
48+
},
4649
state::{BuilderApiState, PbsState},
4750
utils::check_gas_limit,
4851
};
@@ -263,6 +266,7 @@ async fn get_header_from_relay(
263266
return Err(PbsError::WebSocketConnect(err));
264267
}
265268

269+
RELAY_STREAM_FALLBACK.with_label_values(&[relay.id.as_str()]).inc();
266270
warn!(relay_id = relay.id.as_ref(), %err, timeout_left_ms, "stream failed, falling back to http get_header");
267271

268272
let url = relay.get_header_url(params.slot, &params.parent_hash, &params.pubkey)?;

‎crates/pbs/src/mev_boost/get_header_ws.rs‎

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,13 @@ use url::Url;
4242
use super::get_header::{RequestInfo, validate_get_header_response};
4343
use crate::{
4444
constants::{
45-
GET_HEADER_ENDPOINT_TAG, MAX_SIZE_GET_HEADER_RESPONSE, TIMEOUT_ERROR_CODE,
46-
TRANSPORT_ERROR_CODE,
45+
GET_HEADER_STREAM_ENDPOINT_TAG, MAX_SIZE_GET_HEADER_RESPONSE, TIMEOUT_ERROR_STATUS,
46+
TRANSPORT_ERROR_STATUS,
47+
},
48+
metrics::{
49+
RELAY_LATENCY, RELAY_STATUS_CODE, RELAY_STREAM_CONNECT_LATENCY,
50+
RELAY_STREAM_INVALID_FRAMES, RELAY_STREAM_UPDATES,
4751
},
48-
metrics::{RELAY_LATENCY, RELAY_STATUS_CODE},
4952
mev_boost::get_header::decode_ssz_payload,
5053
};
5154

@@ -82,7 +85,7 @@ pub(super) async fn get_header_ws(
8285
) -> Result<Option<GetHeaderResponse>, PbsError> {
8386
let (status, res) = stream_header(request_info, relay, url, timeout_ms).await;
8487
RELAY_STATUS_CODE
85-
.with_label_values(&[status.as_str(), GET_HEADER_ENDPOINT_TAG, &relay.id])
88+
.with_label_values(&[status.as_str(), GET_HEADER_STREAM_ENDPOINT_TAG, &relay.id])
8689
.inc();
8790
res
8891
}
@@ -96,7 +99,7 @@ async fn stream_header(
9699
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
97100
let request = match build_handshake_request(request_info, relay, &url, timeout_ms) {
98101
Ok(request) => request,
99-
Err(err) => return (StatusCode::from_u16(TRANSPORT_ERROR_CODE).unwrap(), Err(err)),
102+
Err(err) => return (TRANSPORT_ERROR_STATUS, Err(err)),
100103
};
101104

102105
let config = WebSocketConfig::default()
@@ -114,13 +117,13 @@ async fn stream_header(
114117
Ok(Ok(connected)) => connected,
115118
Ok(Err(err)) => return connect_failed(&err),
116119
Err(_) => {
117-
return (
118-
StatusCode::from_u16(TIMEOUT_ERROR_CODE).unwrap(),
119-
Err(PbsError::WebSocketTimeout),
120-
);
120+
return (TIMEOUT_ERROR_STATUS, Err(PbsError::WebSocketTimeout));
121121
}
122122
};
123123
let connect_latency = start_request.elapsed();
124+
RELAY_STREAM_CONNECT_LATENCY
125+
.with_label_values(&[relay.id.as_str()])
126+
.observe(connect_latency.as_secs_f64());
124127
debug!(relay_id = relay.id.as_ref(), ?connect_latency, "ws connected");
125128

126129
let timer = sleep_until(deadline);
@@ -174,9 +177,16 @@ async fn stream_header(
174177

175178
drop(stream);
176179

180+
RELAY_STREAM_UPDATES.with_label_values(&[relay.id.as_str()]).observe(updates as f64);
181+
if invalid_frames > 0 {
182+
RELAY_STREAM_INVALID_FRAMES
183+
.with_label_values(&[relay.id.as_str()])
184+
.inc_by(invalid_frames as u64);
185+
}
186+
177187
let Some((fork, bid_bytes)) = latest else {
178188
if let Some(err) = stream_error {
179-
return (StatusCode::from_u16(TRANSPORT_ERROR_CODE).unwrap(), Err(err));
189+
return (TRANSPORT_ERROR_STATUS, Err(err));
180190
}
181191

182192
debug!(relay_id = relay.id.as_ref(), ?connect_latency, invalid_frames, "no header");
@@ -185,7 +195,7 @@ async fn stream_header(
185195

186196
if let Some(first_bid_latency) = first_bid_latency {
187197
RELAY_LATENCY
188-
.with_label_values(&[GET_HEADER_ENDPOINT_TAG, &relay.id])
198+
.with_label_values(&[GET_HEADER_STREAM_ENDPOINT_TAG, &relay.id])
189199
.observe(first_bid_latency.as_secs_f64());
190200
}
191201

@@ -224,10 +234,7 @@ async fn stream_header(
224234
/// the headers, so it can be partial or empty.
225235
fn connect_failed(err: &WsError) -> StreamOutcome {
226236
let WsError::Http(res) = err else {
227-
return (
228-
StatusCode::from_u16(TRANSPORT_ERROR_CODE).unwrap(),
229-
Err(PbsError::WebSocketConnect(err.to_string())),
230-
);
237+
return (TRANSPORT_ERROR_STATUS, Err(PbsError::WebSocketConnect(err.to_string())));
231238
};
232239

233240
let code = res.status();
@@ -238,6 +245,9 @@ fn connect_failed(err: &WsError) -> StreamOutcome {
238245
format!("rejected with {code}: {}", String::from_utf8_lossy(body))
239246
};
240247

248+
// A 2xx handshake answer is a failed connect, not a delivered bid
249+
let code = if code.is_success() { TRANSPORT_ERROR_STATUS } else { code };
250+
241251
(code, Err(PbsError::WebSocketConnect(msg)))
242252
}
243253

@@ -374,10 +384,32 @@ mod tests {
374384
assert_eq!(status, StatusCode::NOT_FOUND);
375385

376386
let (status, res) = connect_failed(&WsError::ConnectionClosed);
377-
assert_eq!(status, StatusCode::from_u16(TRANSPORT_ERROR_CODE).unwrap());
387+
assert_eq!(status, TRANSPORT_ERROR_STATUS);
378388
assert!(matches!(res, Err(PbsError::WebSocketConnect(_))));
379389
}
380390

391+
// A url pointing at a plain http endpoint answers the handshake 200. That
392+
// is the code the stream series uses for a delivered bid, so a failed
393+
// handshake must never carry it.
394+
#[test]
395+
fn test_connect_failed_never_reports_a_success_code() {
396+
for code in [200u16, 204, 299] {
397+
let answered = axum::http::Response::builder().status(code).body(None).unwrap();
398+
let (status, res) = connect_failed(&WsError::Http(Box::new(answered)));
399+
assert_eq!(
400+
status, TRANSPORT_ERROR_STATUS,
401+
"handshake answered {code} counted as a served stream"
402+
);
403+
let Err(PbsError::WebSocketConnect(msg)) = res else { panic!("wrong outcome") };
404+
assert!(msg.contains(&code.to_string()), "{msg}");
405+
}
406+
407+
// A relay's own rejection code still reaches the series unchanged
408+
let moved = axum::http::Response::builder().status(302).body(None).unwrap();
409+
let (status, _) = connect_failed(&WsError::Http(Box::new(moved)));
410+
assert_eq!(status, StatusCode::FOUND);
411+
}
412+
381413
#[test]
382414
fn test_decode_streamed_bid() {
383415
let json_bytes =

‎docs/docs/get_started/running/metrics.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,18 @@ datasources:
9292
Once Grafana is running, you can [import](https://grafana.com/docs/grafana/latest/dashboards/build-dashboards/import-dashboards/) the Commit-Boost dashboards from [here](https://github.com/Commit-Boost/commit-boost-client/tree/main/provisioning/grafana), making sure to select the correct `Prometheus` datasource.
9393

9494

95+
96+
## Bid stream
97+
98+
For a relay with `get_header = "stream"`, every series below is labelled by `relay_id`. The stream's own outcome and time-to-first-bid share the two general relay series under `endpoint="get_header_stream"`; the HTTP fallback keeps `endpoint="get_header"`, so the two divide slots served by the stream from slots served by the fallback.
99+
100+
| Question | Series |
101+
|---|---|
102+
| Is the stream serving bids? | `cb_pbs_relay_status_code_total{endpoint="get_header_stream"}`: `200` a bid was delivered, `204` connected but no bid before the deadline, `555` the bid window ran out during the handshake, `556` a transport error (connect failed, stream broke mid-window, or the handshake was answered with anything but `101`), any other code the relay's own refusal of the upgrade |
103+
| How fast does the first bid arrive? | `cb_pbs_relay_latency{endpoint="get_header_stream"}` |
104+
| Is it falling back to HTTP? | `cb_pbs_relay_stream_fallback_total`: handshake failures that had bid window left to retry over HTTP. One at startup is the registration race; a steady rate means the relay is refusing the stream. The fallback's own results are under `endpoint="get_header"` |
105+
| Is the handshake slow? | `cb_pbs_relay_stream_connect_latency` |
106+
| Is it actually streaming? | `cb_pbs_relay_stream_updates`: bid updates received per window. A healthy relay sends several; windows that carry at most one update mean the stream connects but does not stream |
107+
| Are the frames usable? | `cb_pbs_relay_stream_invalid_frames_total`: frames that could not be parsed as a bid, absent while zero |
108+
109+
Two things the series do not tell apart. A bid that arrives but fails decoding or validation still counts as `200`, the same as over HTTP; the validation error is in the logs. And a request the beacon node abandons mid-window records no outcome at all.

0 commit comments

Comments
 (0)