Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 18 additions & 21 deletions crates/app/src/health/checks.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
//! Health checks: severity, cluster metadata, the check type, the fixed list of
//! 9 checks, and the label-pair helper.
//! 9 checks, and their precompiled label matchers.

use std::sync::LazyLock;

use super::{
checker::QueryFunc,
error::Result,
model::LabelPair,
reducers::{gauge_max, increase},
select::{count_labels, count_non_zero_labels, no_labels, sum_labels},
select::{LabelMatcher, count_labels, count_non_zero_labels, no_labels, sum_labels},
};

/// Severity of a health check.
Expand Down Expand Up @@ -59,13 +60,13 @@ pub(crate) struct Check {
pub(crate) func: fn(&QueryFunc<'_>, &Metadata) -> Result<bool>,
}

/// Convenience constructor for a label pair.
fn label(name: &str, value: &str) -> LabelPair {
LabelPair {
name: name.to_owned(),
value: value.to_owned(),
}
}
/// Label matchers for [`pending_validators`], compiled once.
static PENDING_STATUS_LABELS: LazyLock<[LabelMatcher; 1]> =
LazyLock::new(|| [LabelMatcher::new("status", "pending")]);

/// Label matchers for [`proposal_failures`], compiled once.
static PROPOSAL_DUTY_LABELS: LazyLock<[LabelMatcher; 1]> =
LazyLock::new(|| [LabelMatcher::new("duty", ".*proposal")]);

/// Lossy `i64` → `f64` conversion used only for threshold comparisons.
#[allow(
Expand All @@ -78,7 +79,7 @@ fn to_f64(n: i64) -> f64 {

fn high_error_log_rate(q: &QueryFunc<'_>, m: &Metadata) -> Result<bool> {
// Allow 2 errors per validator.
let value = q.query("app_log_error_total", sum_labels(Vec::new()), increase)?;
let value = q.query("app_log_error_total", sum_labels(&[]), increase)?;
Ok(value > 2.0 * to_f64(m.num_validators))
}

Expand All @@ -87,7 +88,7 @@ fn high_warning_log_rate(q: &QueryFunc<'_>, m: &Metadata) -> Result<bool> {
// but the warn counter is emitted as `app_log_warn_total`, so Charon's own
// check never matches it. We query the emitted name so this check fires.
// Allow 2 warnings per validator.
let value = q.query("app_log_warn_total", sum_labels(Vec::new()), increase)?;
let value = q.query("app_log_warn_total", sum_labels(&[]), increase)?;
Ok(value > 2.0 * to_f64(m.num_validators))
}

Expand All @@ -105,7 +106,7 @@ fn insufficient_connected_peers(q: &QueryFunc<'_>, m: &Metadata) -> Result<bool>
fn pending_validators(q: &QueryFunc<'_>, _m: &Metadata) -> Result<bool> {
let max_val = q.query(
"core_scheduler_validator_status",
count_labels(vec![label("status", "pending")]),
count_labels(&*PENDING_STATUS_LABELS),
gauge_max,
)?;
Ok(max_val > 0.0)
Expand All @@ -114,32 +115,28 @@ fn pending_validators(q: &QueryFunc<'_>, _m: &Metadata) -> Result<bool> {
fn proposal_failures(q: &QueryFunc<'_>, _m: &Metadata) -> Result<bool> {
let value = q.query(
"core_tracker_failed_duties_total",
sum_labels(vec![label("duty", ".*proposal")]),
sum_labels(&*PROPOSAL_DUTY_LABELS),
increase,
)?;
Ok(value > 0.0)
}

fn high_registration_failures_rate(q: &QueryFunc<'_>, _m: &Metadata) -> Result<bool> {
let value = q.query(
"core_bcast_recast_errors_total",
sum_labels(Vec::new()),
increase,
)?;
let value = q.query("core_bcast_recast_errors_total", sum_labels(&[]), increase)?;
Ok(value > 0.0)
}

fn metrics_high_cardinality(q: &QueryFunc<'_>, _m: &Metadata) -> Result<bool> {
let max_val = q.query(
"app_health_metrics_high_cardinality",
sum_labels(Vec::new()),
sum_labels(&[]),
gauge_max,
)?;
Ok(max_val > 0.0)
}

fn using_fallback_beacon_nodes(q: &QueryFunc<'_>, _m: &Metadata) -> Result<bool> {
let max_val = q.query("app_eth2_using_fallback", sum_labels(Vec::new()), gauge_max)?;
let max_val = q.query("app_eth2_using_fallback", sum_labels(&[]), gauge_max)?;
Ok(max_val > 0.0)
}

Expand Down
57 changes: 30 additions & 27 deletions crates/app/src/health/select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,29 @@ pub(crate) fn no_labels() -> Selector {
})
}

/// A label name paired with the compiled regex its value must match, built
/// once per selector rather than once per metric series.
pub(crate) struct LabelMatcher {
name: &'static str,
/// [`None`] if the pattern failed to compile, which never matches.
regex: Option<Regex>,
}

impl LabelMatcher {
pub(crate) fn new(name: &'static str, pattern: &str) -> Self {
Self {
name,
regex: Regex::new(pattern).ok(),
}
}
}

/// Sums the values of series matching all of `labels`.
pub(crate) fn count_labels(labels: Vec<LabelPair>) -> Selector {
pub(crate) fn count_labels(labels: &'static [LabelMatcher]) -> Selector {
Box::new(move |fam: &MetricFamily| {
let mut sum = 0.0_f64;
for metric in &fam.metrics {
if labels_contain(&metric.labels, &labels) {
if labels_contain(&metric.labels, labels) {
sum += metric.value_or_zero();
}
}
Expand All @@ -60,42 +77,28 @@ pub(crate) fn count_labels(labels: Vec<LabelPair>) -> Selector {

/// Sums the values of series matching all of `labels`; errors on non
/// gauge/counter families.
pub(crate) fn sum_labels(labels: Vec<LabelPair>) -> Selector {
pub(crate) fn sum_labels(labels: &'static [LabelMatcher]) -> Selector {
Box::new(move |fam: &MetricFamily| {
if fam.metric_type != MetricType::Gauge && fam.metric_type != MetricType::Counter {
return Err(Error::UnsupportedMetricType);
}
let mut sum = 0.0_f64;
for metric in &fam.metrics {
if labels_contain(&metric.labels, &labels) {
if labels_contain(&metric.labels, labels) {
sum += metric.value_or_zero();
}
}
Ok(Some(gauge_metric(sum)))
})
}

/// Returns true if every pair in `contain` matches some label in `labels`:
/// names must be equal and the `contain` value is matched as a regex against
/// the label value. A regex that fails to compile is treated as no match.
pub(crate) fn labels_contain(labels: &[LabelPair], contain: &[LabelPair]) -> bool {
for c in contain {
let mut found = false;
for l in labels {
if l.name != c.name {
continue;
}
if Regex::new(&c.value)
.map(|re| re.is_match(&l.value))
.unwrap_or(false)
{
found = true;
break;
}
}
if !found {
return false;
}
}
true
/// Returns true if every matcher in `contain` matches some label in `labels`:
/// names must be equal and the matcher's regex must match the label value. A
/// matcher whose regex failed to compile is treated as no match.
pub(crate) fn labels_contain(labels: &[LabelPair], contain: &[LabelMatcher]) -> bool {
contain.iter().all(|c| {
labels
.iter()
.any(|l| l.name == c.name && c.regex.as_ref().is_some_and(|re| re.is_match(&l.value)))
})
}
17 changes: 3 additions & 14 deletions crates/cli/src/commands/test/beacon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ use super::{
helpers::{
AllCategoriesResult, CategoryScore, TestCaseName, TestCategory, TestCategoryResult,
TestResult, TestResultError, TestVerdict, calculate_score, evaluate_highest_rtt,
evaluate_rtt, filter_tests, must_output_to_file_on_quiet, publish_result_to_obol_api,
request_rtt, sort_tests, write_result_to_file, write_result_to_writer,
evaluate_rtt, filter_tests, http_client, must_output_to_file_on_quiet,
publish_result_to_obol_api, request_rtt, sort_tests, write_result_to_file,
write_result_to_writer,
},
};
use crate::{duration::Duration, error::Result as CliResult};
Expand All @@ -29,18 +30,6 @@ use tokio::{
};
use tokio_util::sync::CancellationToken;

/// Per-request timeout for beacon-node diagnostic HTTP calls, so a hostile or
/// slow endpoint cannot stall a diagnostic indefinitely.
const BEACON_HTTP_TIMEOUT: StdDuration = StdDuration::from_secs(10);

/// Builds a diagnostic HTTP client with a request timeout.
fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(BEACON_HTTP_TIMEOUT)
.build()
.unwrap_or_default()
}

const THRESHOLD_BEACON_MEASURE_AVG: StdDuration = StdDuration::from_millis(40);
const THRESHOLD_BEACON_MEASURE_POOR: StdDuration = StdDuration::from_millis(100);
const THRESHOLD_BEACON_LOAD_AVG: StdDuration = StdDuration::from_millis(40);
Expand Down
24 changes: 20 additions & 4 deletions crates/cli/src/commands/test/helpers.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
//! Shared types and helper functions for all test categories.

use serde::{Deserialize, Serialize};
use std::{collections::HashMap, fmt, io::Write, path::Path, time::Duration as StdDuration};
use std::{
collections::HashMap, fmt, io::Write, path::Path, sync::LazyLock, time::Duration as StdDuration,
};

use crate::{
ascii::{append_score, get_category_ascii, get_score_ascii},
Expand Down Expand Up @@ -581,6 +583,22 @@ pub(crate) fn hash_ssz(data: &[u8]) -> CliResult<HashRoot> {
Ok(hasher.hash_root()?)
}

/// Per-request timeout for diagnostic HTTP calls, so a hostile or slow endpoint
/// cannot stall a diagnostic indefinitely.
const DIAG_HTTP_TIMEOUT: StdDuration = StdDuration::from_secs(10);

/// Returns the shared diagnostic HTTP client. One client means one connection
/// pool, so probes keep alive instead of handshaking inside their own RTT.
pub(crate) fn http_client() -> &'static reqwest::Client {
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
reqwest::Client::builder()
.timeout(DIAG_HTTP_TIMEOUT)
.build()
.unwrap_or_default()
});
&CLIENT
}

/// Measures the round-trip time (RTT) for an HTTP request and logs a warning if
/// the response status code doesn't match the expected status.
pub(crate) async fn request_rtt(
Expand All @@ -589,9 +607,7 @@ pub(crate) async fn request_rtt(
body: Option<Vec<u8>>,
expected_status: StatusCode,
) -> CliResult<StdDuration> {
let client = reqwest::Client::new();

let mut request_builder = client.request(method, url.as_ref());
let mut request_builder = http_client().request(method, url.as_ref());

if let Some(body_bytes) = body {
request_builder = request_builder
Expand Down
15 changes: 2 additions & 13 deletions crates/cli/src/commands/test/mev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ use super::{
AllCategoriesResult, TestCategory, TestCategoryResult, TestConfigArgs, TestResult, TestVerdict,
calculate_score,
constants::{SLOT_TIME, SLOTS_IN_EPOCH},
evaluate_rtt, must_output_to_file_on_quiet, publish_result_to_obol_api, request_rtt,
write_result_to_file, write_result_to_writer,
evaluate_rtt, http_client, must_output_to_file_on_quiet, publish_result_to_obol_api,
request_rtt, write_result_to_file, write_result_to_writer,
};
use crate::{
commands::test::TestCaseName,
Expand All @@ -21,20 +21,9 @@ use crate::{
};
use clap::Args;

/// Per-request timeout for MEV/beacon diagnostic HTTP calls.
const MEV_HTTP_TIMEOUT: Duration = Duration::from_secs(10);
/// Maximum diagnostic response body read from a beacon/relay endpoint (16 MB).
const BN_MAX_BODY: usize = 16 * 1024 * 1024;

/// Builds a diagnostic HTTP client with a request timeout so a hostile/slow
/// endpoint cannot stall a diagnostic indefinitely.
fn http_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(MEV_HTTP_TIMEOUT)
.build()
.unwrap_or_default()
}

/// Reads a response body, rejecting bodies that exceed [`BN_MAX_BODY`]. Uses
/// the advertised `Content-Length` for the fast-path reject; the client timeout
/// bounds a slow/absent-length body.
Expand Down
3 changes: 1 addition & 2 deletions crates/cli/src/commands/test/peers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -502,9 +502,8 @@ async fn run_relay_http_tests(

async fn relay_ping_test(url: &str, ct: &CancellationToken) -> TestResult {
let result = TestResult::new("PingRelay");
let client = reqwest::Client::new();
tokio::select! {
res = client.get(url).send() => match res {
res = super::http_client().get(url).send() => match res {
Ok(resp) if resp.status().is_success() => result.ok(),
Ok(resp) => result.fail(TestResultError::from_string(format!("HTTP status {}", resp.status()))),
Err(e) => result.fail(e),
Expand Down
Loading
Loading