use std::sync::Arc;
use std::time::Duration;
use crate::http_client::{self, FetchError, HttpClient};
use crate::ingest::auth_header::{AuthHeader, ScraperAuthOutcome, parse_scraper_auth_header};
use crate::report::metrics::{AlumetScrapeReason, MetricsState};
use crate::score::ops_snapshot_diff::OpsSnapshotDiff;
use crate::score::prom_parser::parse_metric_samples;
use super::apply::apply_scrape;
use super::config::AlumetConfig;
use super::state::{AlumetState, DbEnergyState, monotonic_ms};
const UNSUPPORTED_PLATFORM_FAILURE_THRESHOLD: u32 = 3;
const ZERO_SAMPLE_WARN_THRESHOLD: u32 = 3;
pub(super) async fn fetch_metrics_once(
client: &HttpClient,
uri: &hyper::Uri,
auth: Option<&AuthHeader>,
) -> Result<String, ScraperError> {
let bytes = http_client::fetch_get(
client,
uri,
"perf-sentinel/alumet-scraper",
Duration::from_secs(3),
auth,
)
.await
.map_err(ScraperError::Fetch)?;
String::from_utf8(bytes.to_vec()).map_err(ScraperError::Utf8)
}
#[derive(Debug, thiserror::Error)]
pub(super) enum ScraperError {
#[error("Alumet fetch failed")]
Fetch(#[source] FetchError),
#[error("Alumet response was not valid UTF-8")]
Utf8(#[source] std::string::FromUtf8Error),
}
pub(super) fn scraper_error_reason(err: &ScraperError) -> AlumetScrapeReason {
match err {
ScraperError::Fetch(fe) => fetch_error_reason(fe),
ScraperError::Utf8(_) => AlumetScrapeReason::InvalidUtf8,
}
}
fn fetch_error_reason(err: &FetchError) -> AlumetScrapeReason {
match err {
FetchError::Transport(_) => AlumetScrapeReason::Unreachable,
FetchError::Timeout => AlumetScrapeReason::Timeout,
FetchError::HttpStatus(_) => AlumetScrapeReason::HttpError,
FetchError::BodyRead(_) => AlumetScrapeReason::BodyReadError,
FetchError::RequestBuild(_) => AlumetScrapeReason::RequestError,
}
}
#[must_use]
pub fn spawn_scraper(
cfg: AlumetConfig,
state: Arc<AlumetState>,
db_state: Option<Arc<DbEnergyState>>,
broker_state: Option<Arc<DbEnergyState>>,
metrics: Arc<MetricsState>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
run_scraper_loop(cfg, state, db_state, broker_state, metrics).await;
})
}
#[allow(clippy::too_many_lines)]
async fn run_scraper_loop(
cfg: AlumetConfig,
state: Arc<AlumetState>,
db_state: Option<Arc<DbEnergyState>>,
broker_state: Option<Arc<DbEnergyState>>,
metrics: Arc<MetricsState>,
) {
use std::str::FromStr;
let uri = match hyper::Uri::from_str(&cfg.endpoint) {
Ok(u) => u,
Err(e) => {
tracing::error!(
endpoint = %http_client::redact_endpoint_str(&cfg.endpoint),
error = %e,
"Alumet scraper aborting on invalid endpoint URI"
);
return;
}
};
let redacted = http_client::redact_endpoint(&uri);
let parsed_auth: Option<AuthHeader> = match parse_scraper_auth_header(
cfg.auth_header.as_deref(),
&cfg.endpoint,
&redacted,
"alumet",
) {
ScraperAuthOutcome::Invalid => return,
ScraperAuthOutcome::None => None,
ScraperAuthOutcome::Some(h) => Some(h),
};
let client = http_client::build_client();
let mut ticker = tokio::time::interval(cfg.scrape_interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ticker.tick().await;
let mut snapshot_diff = OpsSnapshotDiff::default();
let mut failure_streak_warned = false;
let mut consecutive_failures: u32 = 0;
let mut unsupported_platform_warned = false;
let mut latches = ScrapeLatches::default();
let mut last_success_ms: u64 = monotonic_ms();
tracing::info!(
endpoint = %redacted,
scrape_interval_secs = cfg.scrape_interval.as_secs(),
metric = %cfg.metric_name,
label = %cfg.label_key,
energy_interval_secs = cfg.energy_interval_secs,
service_count = cfg.service_mappings.len(),
"Alumet scraper started"
);
if cfg.service_mappings.is_empty() && cfg.database.is_none() {
tracing::warn!(
endpoint = %redacted,
"[green.alumet] service_mappings is empty: the scraper will \
poll the endpoint but can never attribute energy to a \
service. Add mappings to publish measured coefficients."
);
}
loop {
ticker.tick().await;
let current_ops = metrics.snapshot_service_io_ops();
let deltas = snapshot_diff.delta_and_advance(current_ops);
match fetch_metrics_once(&client, &uri, parsed_auth.as_ref()).await {
Ok(body) => {
failure_streak_warned = false;
consecutive_failures = 0;
let samples = parse_metric_samples(&body, &cfg.metric_name, &cfg.label_key);
let now = monotonic_ms();
let matched = apply_scrape(
&state,
db_state.as_deref(),
broker_state.as_deref(),
&samples,
&deltas,
&cfg,
now,
);
last_success_ms = now;
metrics.alumet_last_scrape_age_seconds.set(0.0);
metrics.alumet_scrape_success.inc();
post_scrape_bookkeeping(
&samples,
matched,
deltas.len(),
&cfg,
&redacted,
db_state.as_deref(),
broker_state.as_deref(),
now,
&mut latches,
);
}
Err(e) => {
consecutive_failures = consecutive_failures.saturating_add(1);
latches.reset_all();
handle_alumet_failure(
&e,
&metrics,
&redacted,
last_success_ms,
monotonic_ms(),
consecutive_failures,
&mut failure_streak_warned,
&mut unsupported_platform_warned,
);
}
}
}
}
#[derive(Default)]
pub(super) struct ScrapeLatches {
no_samples: WarnOnceStreak,
no_match: WarnOnceStreak,
db_missing: WarnOnceStreak,
broker_missing: WarnOnceStreak,
}
impl ScrapeLatches {
pub(super) fn reset_all(&mut self) {
self.no_samples.reset();
self.no_match.reset();
self.db_missing.reset();
self.broker_missing.reset();
}
}
#[allow(clippy::too_many_arguments)] pub(super) fn post_scrape_bookkeeping(
samples: &[crate::score::prom_parser::PromSample],
matched: usize,
deltas_len: usize,
cfg: &AlumetConfig,
redacted: &str,
db_state: Option<&DbEnergyState>,
broker_state: Option<&DbEnergyState>,
now_ms: u64,
latches: &mut ScrapeLatches,
) {
if let Some(db) = db_state {
db.mark_alive(now_ms);
}
if let Some(broker) = broker_state {
broker.mark_alive(now_ms);
}
track_zero_sample_streak(
samples.len(),
matched,
deltas_len,
cfg.service_mappings.len(),
redacted,
&cfg.metric_name,
&cfg.label_key,
&mut latches.no_samples,
&mut latches.no_match,
);
track_workload_label_streak(
samples,
cfg.database.as_ref().map(|d| d.label_value.as_str()),
"[green.alumet.database]",
cfg,
redacted,
&mut latches.db_missing,
);
track_workload_label_streak(
samples,
cfg.broker.as_ref().map(|b| b.label_value.as_str()),
"[green.alumet.broker]",
cfg,
redacted,
&mut latches.broker_missing,
);
}
pub(super) fn track_workload_label_streak(
samples: &[crate::score::prom_parser::PromSample],
declared: Option<&str>,
section: &str,
cfg: &AlumetConfig,
redacted: &str,
streak: &mut WarnOnceStreak,
) {
let Some(label_value) = declared else {
return;
};
if samples.is_empty() {
return;
}
if samples.iter().any(|s| s.label_value == label_value) {
streak.reset();
} else if streak.tick() {
tracing::warn!(
endpoint = %redacted,
label = %cfg.label_key,
label_value = %label_value,
section = %section,
"Alumet samples flowed but the declared label_value was absent \
under label_key across the last {ZERO_SAMPLE_WARN_THRESHOLD} such \
ticks, so no energy is accumulating for that section. Either the \
value is mistyped (it must match the wire verbatim) or the \
workload is absent from the exposition."
);
}
}
#[allow(clippy::too_many_arguments)]
fn handle_alumet_failure(
err: &ScraperError,
metrics: &MetricsState,
redacted: &str,
last_success_ms: u64,
now_ms: u64,
consecutive_failures: u32,
failure_streak_warned: &mut bool,
unsupported_platform_warned: &mut bool,
) {
let age_secs = now_ms.saturating_sub(last_success_ms) as f64 / 1000.0;
metrics.alumet_last_scrape_age_seconds.set(age_secs);
let reason = scraper_error_reason(err);
metrics.alumet_scrape_failed.inc();
metrics
.alumet_scrape_failed_total
.with_label_values(&[reason.as_str()])
.inc();
if *failure_streak_warned {
tracing::debug!(error = %err, "Alumet scrape failed again");
} else {
tracing::warn!(
error = %err,
endpoint = %redacted,
"Alumet scrape failed; subsequent failures will log at debug level"
);
*failure_streak_warned = true;
}
if !*unsupported_platform_warned
&& consecutive_failures >= UNSUPPORTED_PLATFORM_FAILURE_THRESHOLD
{
tracing::warn!(
endpoint = %redacted,
consecutive_failures = consecutive_failures,
"Alumet endpoint has been unreachable for {UNSUPPORTED_PLATFORM_FAILURE_THRESHOLD} consecutive scrapes. \
Check that the Alumet agent is running with the prometheus-exporter plugin enabled \
and serving metrics at the configured endpoint. \
The daemon is falling back through the precedence chain for affected services. \
See docs/LIMITATIONS.md#alumet-precision-bounds."
);
*unsupported_platform_warned = true;
}
}
#[derive(Default)]
pub(crate) struct WarnOnceStreak {
ticks: u32,
warned: bool,
}
impl WarnOnceStreak {
pub(crate) fn tick(&mut self) -> bool {
self.ticks = self.ticks.saturating_add(1);
if !self.warned && self.ticks >= ZERO_SAMPLE_WARN_THRESHOLD {
self.warned = true;
return true;
}
false
}
pub(crate) fn reset(&mut self) {
self.ticks = 0;
self.warned = false;
}
#[cfg(test)]
pub(crate) fn has_warned(&self) -> bool {
self.warned
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn track_zero_sample_streak(
samples_len: usize,
services_matched: usize,
services_with_ops: usize,
mapping_count: usize,
redacted: &str,
metric_name: &str,
label_key: &str,
no_samples: &mut WarnOnceStreak,
no_match: &mut WarnOnceStreak,
) {
if samples_len == 0 {
if no_samples.tick() {
tracing::warn!(
endpoint = %redacted,
metric = metric_name,
label = label_key,
"Alumet endpoint replied HTTP 200 but no samples matched \
the configured metric across the last {ZERO_SAMPLE_WARN_THRESHOLD} ticks. \
Most common cause: metric_name does not match the wire. \
Alumet's prometheus-exporter prepends `prefix` and \
appends `suffix` (default '_alumet') to every metric \
name, and an energy-attribution series is named after \
the operator's formula. Run \
`curl <endpoint> | grep -i energy` and copy the name \
verbatim. Other cause: label_key absent from the series.",
);
}
} else {
no_samples.reset();
if services_matched == 0 && mapping_count > 0 {
if no_match.tick() {
tracing::warn!(
endpoint = %redacted,
metric = metric_name,
label = label_key,
samples = samples_len,
"Alumet endpoint replied HTTP 200 and metric_name matched \
samples, but none of the configured service_mappings \
label values were present under label_key across the \
last {ZERO_SAMPLE_WARN_THRESHOLD} such ticks, so no measured \
coefficient is being published. Either the mapping \
values are mistyped (they must match the label value \
verbatim), or every mapped workload is currently absent \
from the exposition (scaled to zero, not yet scheduled). \
Inspect the live values for the configured label key \
with curl against the endpoint.",
);
}
} else {
no_match.reset();
}
}
tracing::debug!(
samples = samples_len,
services_matched = services_matched,
services_with_ops = services_with_ops,
"Alumet scrape succeeded"
);
}