use std::fs;
use std::path::Path;
use std::time::Duration;
use super::*;
fn has_metric_line(text: &str, name: &str, value: u64) -> bool {
let needle = format!("{name} {value}");
text.lines().any(|l| l == needle)
}
fn metric_value(text: &str, name: &str) -> Option<u64> {
text.lines()
.find_map(|l| l.strip_prefix(name)?.strip_prefix(' ')?.parse::<u64>().ok())
}
#[test]
fn latency_histograms_export_buckets_sum_and_count() {
const HISTOGRAMS: [&str; 10] = [
"decdn_probe_collection_latency_seconds",
"decdn_serve_first_byte_hit_seconds",
"decdn_serve_first_byte_miss_seconds",
"decdn_node_pull_first_byte_seconds",
"decdn_node_pull_through_wait_seconds",
"decdn_serve_admission_seconds",
"decdn_serve_response_hit_seconds",
"decdn_serve_response_miss_seconds",
"decdn_serve_first_frame_hit_seconds",
"decdn_serve_first_frame_miss_seconds",
];
let metrics = Metrics::new();
let text = metrics.encode().unwrap();
let exported = exported_series(&text);
for name in HISTOGRAMS {
let type_line = format!("# TYPE {name} histogram");
assert!(
text.lines().any(|l| l == type_line),
"missing `{type_line}`"
);
assert!(
has_metric_line(&text, &format!("{name}_bucket{{le=\"+Inf\"}}"), 0),
"{name} has no +Inf bucket at zero"
);
assert!(has_metric_line(&text, &format!("{name}_count"), 0));
assert!(
exported.contains(name),
"exported_series did not fold {name}"
);
}
let elapsed = Duration::from_millis(40);
metrics.probe_collection_latency(elapsed);
metrics.node_pull_first_byte(elapsed);
let now = Instant::now();
FirstByteClock::new(now, now, RequestClass::CacheHit).record(&metrics);
let text = metrics.encode().unwrap();
for name in [HISTOGRAMS[0], HISTOGRAMS[3]] {
assert!(has_metric_line(
&text,
&format!("{name}_bucket{{le=\"0.025\"}}"),
0
));
assert!(has_metric_line(
&text,
&format!("{name}_bucket{{le=\"0.05\"}}"),
1
));
assert!(has_metric_line(
&text,
&format!("{name}_bucket{{le=\"+Inf\"}}"),
1
));
assert!(has_metric_line(&text, &format!("{name}_count"), 1));
let sum_line = format!("{name}_sum 0.04");
assert!(text.lines().any(|l| l == sum_line), "missing `{sum_line}`");
}
assert!(has_metric_line(
&text,
"decdn_serve_first_byte_hit_seconds_count",
1
));
assert!(has_metric_line(
&text,
"decdn_serve_first_byte_miss_seconds_count",
0
));
}
#[test]
fn first_byte_clock_records_each_phase_once() {
let metrics = Metrics::new();
let now = Instant::now();
let mut miss = FirstByteClock::new(now, now, RequestClass::CacheMiss);
miss.mark_responded();
miss.record(&metrics);
FirstByteClock::new(now, now, RequestClass::CacheHit).record(&metrics);
let text = metrics.encode().unwrap();
for (name, count) in [
("decdn_serve_admission_seconds_count", 2),
("decdn_serve_response_miss_seconds_count", 1),
("decdn_serve_response_hit_seconds_count", 0),
("decdn_serve_first_frame_miss_seconds_count", 1),
("decdn_serve_first_frame_hit_seconds_count", 0),
("decdn_serve_first_byte_miss_seconds_count", 1),
("decdn_serve_first_byte_hit_seconds_count", 1),
] {
assert!(has_metric_line(&text, name, count), "{name} != {count}");
}
}
#[test]
fn first_byte_buckets_reach_past_the_default_outer_pull_deadline() {
use decdn_common::config::{DEFAULT_NODE_PULL_STALL_WINDOW_SEC, DEFAULT_NODE_PULL_TIMEOUT_SEC};
let deadline = crate::selection::outer_pull_deadline(
Duration::from_secs(DEFAULT_NODE_PULL_TIMEOUT_SEC),
Duration::from_secs(DEFAULT_NODE_PULL_STALL_WINDOW_SEC),
);
let top = FIRST_BYTE_BUCKETS.last().copied().unwrap_or_default();
assert!(
top > deadline.as_secs_f64(),
"top first-byte bucket {top}s does not reach the default outer pull deadline \
{deadline:?}"
);
}
#[test]
fn key_rotation_metrics_export_canonical_names_at_zero() {
let metrics = Metrics::new();
let text = metrics.encode().unwrap();
for name in [
"decdn_streams_active{direction=\"inbound\"}",
"decdn_streams_active{direction=\"outbound\"}",
"decdn_lanes_open",
"decdn_pool_deposit_usdc",
"decdn_unredeemed_usdc",
"decdn_node_uptime_seconds",
] {
assert!(
has_metric_line(&text, name, 0),
"canonical gauge {name} should be exposed at zero:\n{text}"
);
}
assert!(
!text
.lines()
.any(|line| line.starts_with("decdn_uptime_seconds ")),
"retired uptime name must not be exported:\n{text}"
);
}
#[test]
fn set_unredeemed_usdc_publishes_the_total() {
let metrics = Metrics::new();
metrics.set_unredeemed_usdc(U256::from(12_345u64));
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_unredeemed_usdc", 12_345),
"gauge must publish the set total:\n{text}"
);
metrics.set_unredeemed_usdc(U256::from(42u64));
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_unredeemed_usdc", 42),
"gauge must overwrite on the next sweep:\n{text}"
);
}
#[test]
fn probe_hold_unavailable_exports_every_reason_at_zero() {
let metrics = Metrics::new();
let text = metrics.encode().unwrap();
for reason in ["exhausted", "disabled", "stake_lane_reserved"] {
let name = format!("decdn_probe_hold_unavailable_total{{reason=\"{reason}\"}}");
assert!(
has_metric_line(&text, &name, 0),
"{name} should be exposed at zero from a fresh registry:\n{text}"
);
}
for retired in [
"decdn_probe_hold_violations_total",
"decdn_probe_holds_disabled_total",
"decdn_probe_stake_lane_reserved_total",
] {
assert!(
!text.lines().any(|line| line.starts_with(retired)),
"retired metric name {retired} must not be exported:\n{text}"
);
}
}
#[test]
fn rpc_request_series_export_at_zero_and_record_per_label() {
let metrics = Metrics::new();
let text = metrics.encode().unwrap();
for method in RpcMethod::ALL {
let method = method.as_str();
for outcome in [
"ok",
"reverted",
"rpc_error",
"rate_limited",
"transport_error",
"timeout",
] {
let name =
format!("decdn_rpc_requests_total{{method=\"{method}\",outcome=\"{outcome}\"}}");
assert!(has_metric_line(&text, &name, 0), "{name} missing:\n{text}");
}
let count = format!("decdn_rpc_request_duration_seconds_count{{method=\"{method}\"}}");
assert!(has_metric_line(&text, &count, 0), "{count} missing");
}
assert_eq!(RpcOutcome::ALL.len(), 6, "extend the outcome list above");
metrics.rpc_request(
RpcMethod::EthGetLogs,
RpcOutcome::RateLimited,
Some(Duration::from_millis(40)),
);
metrics.rpc_request(RpcMethod::EthCall, RpcOutcome::Timeout, None);
let text = metrics.encode().unwrap();
let requests = |method: &str, outcome: &str| {
metric_value(
&text,
&format!("decdn_rpc_requests_total{{method=\"{method}\",outcome=\"{outcome}\"}}"),
)
};
assert_eq!(requests("eth_getLogs", "rate_limited"), Some(1));
assert_eq!(requests("eth_getLogs", "ok"), Some(0));
assert_eq!(requests("eth_call", "timeout"), Some(1));
assert!(has_metric_line(
&text,
"decdn_rpc_request_duration_seconds_bucket{method=\"eth_getLogs\",le=\"0.05\"}",
1
));
assert!(has_metric_line(
&text,
"decdn_rpc_request_duration_seconds_count{method=\"eth_call\"}",
0
));
}
#[test]
fn rpc_request_buckets_reach_the_per_call_deadline() {
let top = RPC_REQUEST_BUCKETS.last().copied().unwrap_or_default();
assert!(top >= crate::chain_events::DEFAULT_RPC_CALL_TIMEOUT.as_secs_f64());
}
fn exported_series(text: &str) -> std::collections::HashSet<String> {
text.lines()
.filter(|l| !l.starts_with('#') && !l.is_empty())
.filter_map(|l| {
let name = l.split(['{', ' ']).next()?;
(!name.is_empty()).then(|| name.to_string())
})
.flat_map(|name| {
let base = ["_bucket", "_sum", "_count"]
.iter()
.find_map(|sfx| name.strip_suffix(sfx))
.map(str::to_string);
std::iter::once(name).chain(base)
})
.collect()
}
#[test]
fn adr_registry_names_are_exported() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let text = fs::read_to_string(root.join("adr/appendix-observability.md")).unwrap();
let exported = exported_series(&Metrics::new().encode().unwrap());
let mut checked = 0usize;
let mut stale: Vec<String> = Vec::new();
for line in text.lines() {
let cells: Vec<&str> = line.split('|').map(str::trim).collect();
let (Some(metric), Some(kind), Some(status)) = (cells.get(1), cells.get(2), cells.get(4))
else {
continue;
};
if !matches!(*kind, "Counter" | "Gauge" | "Histogram") {
continue;
}
let Some(name) = metric.strip_prefix('`').and_then(|m| m.split('`').next()) else {
continue;
};
if name.contains('{') || name.contains('<') {
continue;
}
if *status == "planned" {
continue;
}
assert_eq!(
*status, "live",
"row for {name} has Status {status:?}; the only values are `live` and `planned`"
);
checked += 1;
if !exported.contains(name) {
stale.push(name.to_string());
}
}
assert!(
checked >= 40,
"only {checked} registry rows parsed — the table shape changed and this \
gate silently stopped covering the registry"
);
assert!(
stale.is_empty(),
"adr/appendix-observability.md documents series the exporter does not emit: \
{stale:?}\nEither correct the name or mark the row `planned` in its Status \
column."
);
}
#[test]
fn staker_set_active_by_region_replaces_the_previous_map() {
let metrics = Metrics::new();
let de = Region::parse("DE").unwrap();
let us = Region::parse("US").unwrap();
metrics.staker_set_active_by_region(&BTreeMap::from([(de, 2), (us, 1)]), 1);
metrics.staker_set_active_by_region(&BTreeMap::from([(de, 1)]), 0);
let text = metrics.encode().unwrap();
let has = |line: &str| text.lines().any(|l| l == line);
assert!(
has(r#"decdn_staker_set_active_by_region{node_region="DE"} 1"#),
"{text}"
);
assert!(
!text.contains(r#"node_region="US""#),
"a departed region kept its series"
);
assert!(has("decdn_staker_set_active_unknown_region 0"), "{text}");
}
#[test]
fn probe_hold_unavailable_increments_only_the_named_reason() {
let metrics = Metrics::new();
metrics.probe_hold_unavailable(ProbeHoldUnavailableReason::Exhausted);
let text = metrics.encode().unwrap();
assert!(
has_metric_line(
&text,
"decdn_probe_hold_unavailable_total{reason=\"exhausted\"}",
1
),
"the named reason must increment:\n{text}"
);
for untouched in ["disabled", "stake_lane_reserved"] {
let name = format!("decdn_probe_hold_unavailable_total{{reason=\"{untouched}\"}}");
assert!(
has_metric_line(&text, &name, 0),
"{name} must stay at zero:\n{text}"
);
}
}
#[test]
fn uptime_counts_from_construction_without_any_bring_up_hook() {
const BACKDATE_SECS: u64 = 60;
let mut metrics = Metrics::new();
metrics.started_at = Instant::now()
.checked_sub(Duration::from_secs(BACKDATE_SECS))
.unwrap_or_else(Instant::now);
let text = metrics.encode().unwrap();
let uptime = metric_value(&text, "decdn_node_uptime_seconds").unwrap();
assert!(
(BACKDATE_SECS..3600).contains(&uptime),
"uptime must count from construction (started_at ~{BACKDATE_SECS}s ago), got {uptime}:\n{text}"
);
}
#[test]
fn stream_guards_track_direction_and_drop_on_error() {
fn inbound_error(metrics: &Metrics) -> Result<(), ()> {
let _guard = metrics.inbound_stream_guard();
let text = metrics.encode().unwrap();
assert!(has_metric_line(
&text,
"decdn_streams_active{direction=\"inbound\"}",
1
));
Err(())
}
let metrics = Metrics::new();
assert!(inbound_error(&metrics).is_err());
assert!(has_metric_line(
&metrics.encode().unwrap(),
"decdn_streams_active{direction=\"inbound\"}",
0
));
{
let _first = metrics.outbound_stream_guard();
let _second = metrics.outbound_stream_guard();
assert!(has_metric_line(
&metrics.encode().unwrap(),
"decdn_streams_active{direction=\"outbound\"}",
2
));
}
assert!(has_metric_line(
&metrics.encode().unwrap(),
"decdn_streams_active{direction=\"outbound\"}",
0
));
}
#[test]
fn cache_metrics_counters_start_at_zero() {
let metrics = Metrics::new();
let text = metrics.encode().unwrap();
for name in [
"decdn_cache_origin_fetches_total",
"decdn_cache_origin_retry_exhausted_total",
"decdn_cache_origin_fallback_total",
"decdn_cache_hits_total",
"decdn_cache_misses_total",
"decdn_cache_bytes_returned_total",
"decdn_cache_pull_through_bytes_total",
"decdn_cache_gc_runs_total",
"decdn_cache_gc_bytes_reclaimed_total",
"decdn_cache_circuit_breaker_trips_total",
"decdn_cache_circuit_breaker_recoveries_total",
"decdn_cache_circuit_breaker_short_circuits_total",
"decdn_cache_evictions_total",
"decdn_cache_evictions_bytes_total",
"decdn_cache_evictions_starved_total",
"decdn_cache_size_measure_failures_total",
"decdn_cache_recency_seed_failures_total",
"decdn_cache_evicted_operator_total",
"decdn_cache_partial_size_observe_failures_total",
"decdn_cache_held_corruption_quarantined_total",
"decdn_cache_tag_drop_failures_total",
"decdn_cache_inflight_mutex_poisoned_total",
"decdn_cache_origin_probe_failures_total",
"decdn_cache_origin_enumerate_failures_total",
] {
assert!(
has_metric_line(&text, name, 0),
"counter {name} should be exposed at zero on a fresh registry:\n{text}"
);
}
for name in [
"decdn_cache_bytes",
"decdn_cache_size_limit_bytes",
"decdn_cache_pinned_count",
] {
assert!(
has_metric_line(&text, name, 0),
"gauge {name} should be exposed at zero on a fresh registry:\n{text}"
);
}
}
#[test]
fn quic_0rtt_and_session_ticket_series_are_not_exposed() {
let metrics = Metrics::new();
let text = metrics.encode().unwrap();
for needle in ["0rtt", "session_ticket"] {
assert!(
!text.contains(needle),
"exposition must not carry a {needle} series:\n{text}"
);
}
}
#[test]
fn buyer_pool_skipped_undecodable_metric_starts_at_zero_and_increments() {
let metrics = Metrics::new();
let text = metrics.encode().unwrap();
assert!(
has_metric_line(
&text,
"decdn_buyer_pool_store_skipped_undecodable_records_total",
0
),
"skipped-undecodable counter should be exposed at zero on a fresh registry:\n{text}"
);
metrics.buyer_pool_store_skipped_undecodable_records(2);
let text = metrics.encode().unwrap();
assert!(
has_metric_line(
&text,
"decdn_buyer_pool_store_skipped_undecodable_records_total",
2
),
"expected the per-load skipped count (2) to increment the counter:\n{text}"
);
}
#[test]
fn serve_stream_rejected_counters_start_at_zero_and_increment_per_reason() {
let metrics = Metrics::new();
let reasons = [
"decdn_serve_stream_rejected_evicted_since_probe_total",
"decdn_serve_stream_rejected_cache_miss_total",
"decdn_serve_stream_rejected_internal_error_total",
"decdn_serve_stream_rejected_unknown_lane_total",
"decdn_serve_stream_rejected_owner_mismatch_total",
"decdn_serve_stream_rejected_insufficient_deposit_total",
"decdn_serve_stream_rejected_pool_unconfirmed_total",
"decdn_serve_stream_rejected_pool_closing_total",
"decdn_serve_stream_rejected_signer_cap_exhausted_total",
"decdn_serve_stream_midstream_signer_cap_exhausted_total",
"decdn_serve_stream_rejected_signer_floor_at_cap_total",
"decdn_serve_stream_rejected_pull_loop_guard_total",
"decdn_serve_stream_rejected_range_not_satisfiable_total",
"decdn_serve_stream_rejected_blob_too_large_total",
"decdn_serve_stream_rejected_hash_denied_total",
"decdn_serve_stream_rejected_chain_hash_denied_total",
"decdn_serve_stream_rejected_origin_denied_total",
"decdn_serve_stream_rejected_foreign_declined_total",
"decdn_serve_stream_rejected_chain_stale_total",
];
let text = metrics.encode().unwrap();
for name in reasons {
assert!(
has_metric_line(&text, name, 0),
"reject counter {name} should be exposed at zero on a fresh registry:\n{text}"
);
}
metrics.serve_stream_rejected_evicted_since_probe();
metrics.serve_stream_rejected_cache_miss();
metrics.serve_stream_rejected_internal_error();
metrics.serve_stream_rejected_unknown_lane();
metrics.serve_stream_rejected_owner_mismatch();
metrics.serve_stream_rejected_insufficient_deposit();
metrics.serve_stream_rejected_pool_unconfirmed();
metrics.serve_stream_rejected_pool_closing();
metrics.serve_stream_rejected_signer_cap_exhausted();
metrics.serve_stream_midstream_signer_cap_exhausted();
metrics.serve_stream_rejected_signer_floor_at_cap();
metrics.serve_stream_rejected_pull_loop_guard();
metrics.serve_stream_rejected_range_not_satisfiable();
metrics.serve_stream_rejected_blob_too_large();
metrics.serve_stream_rejected_hash_denied();
metrics.serve_stream_rejected_chain_hash_denied();
metrics.serve_stream_rejected_origin_denied();
metrics.serve_stream_rejected_foreign_declined();
metrics.serve_stream_rejected_chain_stale();
let text = metrics.encode().unwrap();
for name in reasons {
assert!(
has_metric_line(&text, name, 1),
"reject counter {name} should read exactly 1 after one bump:\n{text}"
);
}
}
#[test]
fn load_shed_metrics_appear_in_scrape() {
let metrics = Metrics::new();
metrics.serve_stream_rejected_load_shed_hit();
metrics.serve_stream_rejected_load_shed_miss();
metrics.load_shed_egress_bps(1_234);
metrics.load_shed_pressure_active(true);
metrics.load_shed_streams_in_flight(7);
let text = metrics.encode().unwrap();
assert!(
text.contains("decdn_serve_stream_rejected_load_shed_hit_total 1"),
"{text}"
);
assert!(
text.contains("decdn_serve_stream_rejected_load_shed_miss_total 1"),
"{text}"
);
assert!(text.contains("decdn_load_shed_egress_bps 1234"), "{text}");
assert!(text.contains("decdn_load_shed_pressure_active 1"), "{text}");
assert!(
has_metric_line(&text, "decdn_load_shed_streams_in_flight", 7),
"{text}"
);
}
#[test]
fn load_shed_refused_counters_start_at_zero_and_increment_per_reason() {
use crate::load_shed::ShedReason;
let metrics = Metrics::new();
let names = [
"decdn_load_shed_refused_node_at_capacity_total",
"decdn_load_shed_refused_egress_saturated_total",
"decdn_load_shed_refused_client_at_capacity_total",
];
let text = metrics.encode().unwrap();
for name in names {
assert!(
has_metric_line(&text, name, 0),
"{name} not at zero:\n{text}"
);
}
metrics.load_shed_refused(ShedReason::NodeAtCapacity);
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_load_shed_refused_node_at_capacity_total", 1),
"the named reason must increment:\n{text}"
);
for untouched in [
"decdn_load_shed_refused_egress_saturated_total",
"decdn_load_shed_refused_client_at_capacity_total",
] {
assert!(
has_metric_line(&text, untouched, 0),
"{untouched} must stay at zero:\n{text}"
);
}
}
#[test]
fn serve_economics_refused_splits_by_regime_and_bumps_the_aggregate() {
let metrics = Metrics::new();
let text = metrics.encode().unwrap();
for name in [
"decdn_serve_economics_refused_total",
"decdn_serve_economics_refused_warming_total",
"decdn_serve_economics_refused_amortized_total",
] {
assert!(
has_metric_line(&text, name, 0),
"{name} not at zero:\n{text}"
);
}
metrics.serve_economics_refused(ServeEconomicsRegime::Warming);
metrics.serve_economics_refused(ServeEconomicsRegime::Amortized);
metrics.serve_economics_refused(ServeEconomicsRegime::Amortized);
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_serve_economics_refused_total", 3),
"aggregate must sum both regimes:\n{text}"
);
assert!(
has_metric_line(&text, "decdn_serve_economics_refused_warming_total", 1),
"{text}"
);
assert!(
has_metric_line(&text, "decdn_serve_economics_refused_amortized_total", 2),
"{text}"
);
}
#[test]
fn warming_block_metrics_start_at_zero_and_increment() {
let metrics = Metrics::new();
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_warming_speculative_blocked_total", 0),
"{text}"
);
assert!(
has_metric_line(&text, "decdn_warming_sources_blocked", 0),
"{text}"
);
metrics.warming_speculative_blocked();
metrics.warming_sources_blocked(4);
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_warming_speculative_blocked_total", 1),
"{text}"
);
assert!(
has_metric_line(&text, "decdn_warming_sources_blocked", 4),
"{text}"
);
}
#[test]
fn observability_counters_export_at_zero() {
let text = Metrics::new().encode().unwrap();
for name in [
"decdn_bytes_served_total",
"decdn_bytes_received_total",
"decdn_pool_redemptions_total",
"decdn_vouchers_received_total",
"decdn_preimage_reveals_received_total",
"decdn_pool_grace_closes_total",
"decdn_onchain_tx_landed_total",
"decdn_onchain_tx_reverted_total",
"decdn_onchain_tx_send_failed_total",
"decdn_onchain_tx_receipt_failed_total",
"decdn_onchain_tx_timeout_total",
"decdn_onchain_tx_receipt_recovered_total",
"decdn_chain_boot_read_retries_total",
"decdn_chain_get_logs_retries_total",
"decdn_chain_get_logs_deferred_total",
"decdn_dht_findvalue_queries_total",
"decdn_dht_lookup_round_timeouts_total",
"decdn_dht_store_published_total",
"decdn_dht_routing_table_size",
"decdn_dht_bucket_refresh_failures_total",
"decdn_dht_bootstrap_find_node_failures_total",
"decdn_fee_shares_watcher_poll_failures_total",
"decdn_fee_shares_watcher_restarts_total",
"decdn_fee_shares_watcher_down_seconds",
"decdn_fee_shares_watcher_unregistered",
"decdn_config_reload_failures_total",
"decdn_receipt_write_failures_total",
"decdn_serve_stream_midstream_pool_exhausted_total",
"decdn_serve_stream_proof_budget_exhausted_total",
"decdn_redeem_hints_parked_total",
] {
assert!(
has_metric_line(&text, name, 0),
"{name} should export at zero on a fresh registry:\n{text}"
);
}
}
#[test]
fn inbound_failure_reasons_export_at_zero() {
let text = Metrics::new().encode().unwrap();
for name in INBOUND_FAILURE_REASONS {
assert!(
has_metric_line(&text, name, 0),
"{name} should export at zero on a fresh registry:\n{text}"
);
}
}
#[test]
fn every_serve_stream_counter_is_an_inbound_failure_reason() {
let exported = exported_series(&Metrics::new().encode().unwrap());
let missing: Vec<&String> = exported
.iter()
.filter(|n| n.starts_with("decdn_serve_stream_") && n.ends_with("_total"))
.filter(|n| !INBOUND_FAILURE_REASONS.contains(&n.as_str()))
.collect();
assert!(
missing.is_empty(),
"serve-stream counters missing from INBOUND_FAILURE_REASONS: {missing:?}"
);
}
#[test]
fn stream_outcomes_split_by_direction() {
let metrics = Metrics::new();
let text = metrics.encode().unwrap();
for family in [
"decdn_streams_completed_total",
"decdn_streams_failed_total",
] {
for direction in ["inbound", "outbound"] {
let line = format!("{family}{{direction=\"{direction}\"}} 0");
assert!(text.lines().any(|l| l == line), "missing {line}:\n{text}");
}
}
metrics.inbound_stream_ended(true);
metrics.inbound_stream_ended(false);
metrics.inbound_stream_ended(false);
metrics.outbound_stream_ended(true);
let text = metrics.encode().unwrap();
for line in [
"decdn_streams_completed_total{direction=\"inbound\"} 1",
"decdn_streams_failed_total{direction=\"inbound\"} 2",
"decdn_streams_completed_total{direction=\"outbound\"} 1",
"decdn_streams_failed_total{direction=\"outbound\"} 0",
] {
assert!(text.lines().any(|l| l == line), "missing {line}:\n{text}");
}
}
#[test]
fn receipt_writes_dropped_metric_starts_at_zero_and_increments() {
let metrics = Metrics::new();
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_receipt_writes_dropped_total", 0),
"receipt-write-dropped counter should be exposed at zero on a fresh registry:\n{text}"
);
metrics.receipt_write_dropped();
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_receipt_writes_dropped_total", 1),
"expected one dropped-receipt event:\n{text}"
);
}
#[test]
fn warming_credits_dropped_metric_starts_at_zero_and_increments() {
let metrics = Metrics::new();
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_warming_credits_dropped_total", 0),
"warming-credit-dropped counter should be exposed at zero on a fresh registry:\n{text}"
);
metrics.warming_credit_dropped();
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_warming_credits_dropped_total", 1),
"expected one dropped-warming-credit event:\n{text}"
);
}
#[test]
fn warming_credits_applied_metric_starts_at_zero_and_increments() {
let metrics = Metrics::new();
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_warming_credits_applied_total", 0),
"warming-credit-applied counter should be exposed at zero on a fresh registry:\n{text}"
);
metrics.warming_credit_applied();
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_warming_credits_applied_total", 1),
"expected one applied warming credit:\n{text}"
);
}
#[test]
fn staker_set_watcher_metrics_start_at_zero_and_increment() {
let metrics = Metrics::new();
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_staker_set_watcher_restarts_total", 0),
"watcher restart counter should be exposed at zero on a fresh registry:\n{text}"
);
assert!(
has_metric_line(&text, "decdn_staker_set_watcher_down_seconds", 0),
"watcher down-seconds gauge should start at zero:\n{text}"
);
assert!(
has_metric_line(&text, "decdn_staker_set_active_count", 0),
"active-count gauge should start at zero:\n{text}"
);
assert!(
has_metric_line(&text, "decdn_staker_set_watcher_resolve_failures_total", 0),
"resolve-failure counter should start at zero:\n{text}"
);
metrics.staker_set_watcher_backoff_started();
metrics.staker_set_watcher_cycle_established();
metrics.staker_set_watcher_backoff_started();
metrics.staker_set_watcher_resolve_failure();
metrics.staker_set_active_count(7);
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_staker_set_watcher_restarts_total", 2),
"expected 2 restart events:\n{text}"
);
assert!(
has_metric_line(&text, "decdn_staker_set_watcher_resolve_failures_total", 1),
"expected 1 resolve failure:\n{text}"
);
assert!(
has_metric_line(&text, "decdn_staker_set_active_count", 7),
"expected active-count gauge to report 7:\n{text}"
);
}
#[test]
fn staker_set_watcher_down_seconds_reads_zero_across_a_long_healthy_cycle() {
let mut metrics = Metrics::new();
metrics.started_at = Instant::now()
.checked_sub(Duration::from_hours(1))
.unwrap_or_else(Instant::now);
metrics.staker_set_watcher_cycle_established();
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_node_uptime_seconds", 3600),
"uptime should reflect the backdated start:\n{text}"
);
assert!(
has_metric_line(&text, "decdn_staker_set_watcher_down_seconds", 0),
"down-seconds must read 0 across a long healthy cycle (true downtime, not age):\n{text}"
);
metrics.staker_set_watcher_backoff_started();
if let Ok(mut down_since) = metrics.staker_set_watcher_down_since.lock() {
*down_since = Instant::now().checked_sub(Duration::from_secs(150));
}
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_staker_set_watcher_down_seconds", 150),
"down-seconds should climb to the downtime depth once in backoff:\n{text}"
);
metrics.staker_set_watcher_cycle_established();
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_staker_set_watcher_down_seconds", 0),
"down-seconds should reset to 0 once filters re-establish:\n{text}"
);
}
#[test]
fn slash_watcher_down_seconds_tracks_true_downtime() {
let mut metrics = Metrics::new();
metrics.started_at = Instant::now()
.checked_sub(Duration::from_hours(1))
.unwrap_or_else(Instant::now);
metrics.slash_watcher_cycle_established();
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_slash_watcher_down_seconds", 0),
"down-seconds must read 0 across a long healthy cycle:\n{text}"
);
metrics.slash_watcher_backoff_started();
if let Ok(mut down_since) = metrics.slash_watcher_down_since.lock() {
*down_since = Instant::now().checked_sub(Duration::from_secs(150));
}
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_slash_watcher_down_seconds", 150),
"down-seconds should climb to the downtime depth once in backoff:\n{text}"
);
metrics.slash_watcher_backoff_started();
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_slash_watcher_restarts_total", 1),
"restarts must bump once per drift window, not per call:\n{text}"
);
metrics.slash_watcher_cycle_established();
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_slash_watcher_down_seconds", 0),
"down-seconds should reset to 0 once the cycle re-establishes:\n{text}"
);
}
#[test]
#[allow(clippy::panic)] fn poisoned_down_since_reports_i64_max_not_zero() {
let metrics = Arc::new(Metrics::new());
let for_thread = Arc::clone(&metrics);
let join = std::thread::spawn(move || {
let _g = for_thread.slash_watcher_down_since.lock().unwrap();
panic!("intentional");
});
let _ = join.join();
assert!(metrics.slash_watcher_down_since.is_poisoned());
let i64_max = u64::try_from(i64::MAX).unwrap();
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_slash_watcher_down_seconds", i64_max),
"a poisoned down_since must report i64::MAX, not 0:\n{text}"
);
assert!(
has_metric_line(&text, "decdn_staker_set_watcher_down_seconds", 0),
"a poisoned slash lock must not perturb the staker-set gauge:\n{text}"
);
for name in [
"decdn_blacklist_watcher_down_seconds",
"decdn_settlement_watcher_down_seconds",
] {
assert!(
has_metric_line(&text, name, 0),
"a poisoned slash lock must not perturb {name}:\n{text}"
);
}
}
#[test]
fn watcher_liveness_gauges_stamp_wall_clock_on_tick() {
let metrics = Metrics::new();
let before = unix_now_secs();
metrics.slash_watcher_tick();
metrics.staker_set_watcher_tick();
metrics.blacklist_watcher_tick();
metrics.settlement_watcher_tick();
let text = metrics.encode().unwrap();
for name in [
"decdn_slash_watcher_last_tick_timestamp_seconds",
"decdn_staker_set_watcher_last_tick_timestamp_seconds",
"decdn_blacklist_watcher_last_tick_timestamp_seconds",
"decdn_settlement_watcher_last_tick_timestamp_seconds",
] {
let floor = u64::try_from(before).unwrap();
assert!(
metric_value(&text, name).is_some_and(|stamped| stamped >= floor),
"liveness gauge {name} must be exported and stamped with the current time:\n{text}"
);
}
}
#[test]
#[allow(clippy::type_complexity)] fn new_watchers_down_seconds_track_true_downtime() {
let cases: [(fn(&Metrics), fn(&Metrics), &str, &str); 3] = [
(
Metrics::blacklist_watcher_cycle_established,
Metrics::blacklist_watcher_backoff_started,
"decdn_blacklist_watcher_down_seconds",
"decdn_blacklist_watcher_restarts_total",
),
(
Metrics::settlement_watcher_cycle_established,
Metrics::settlement_watcher_backoff_started,
"decdn_settlement_watcher_down_seconds",
"decdn_settlement_watcher_restarts_total",
),
(
Metrics::fee_shares_watcher_cycle_established,
Metrics::fee_shares_watcher_backoff_started,
"decdn_fee_shares_watcher_down_seconds",
"decdn_fee_shares_watcher_restarts_total",
),
];
for (established, backoff, down_seconds, restarts) in cases {
let metrics = Metrics::new();
established(&metrics);
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, down_seconds, 0),
"{down_seconds} must read 0 on a healthy cycle:\n{text}"
);
backoff(&metrics);
backoff(&metrics); let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, restarts, 1),
"{restarts} must bump once per drift window:\n{text}"
);
established(&metrics);
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, down_seconds, 0),
"{down_seconds} must reset to 0 once re-established:\n{text}"
);
}
}
#[derive(Debug)]
struct StubOrigin {
data: bytes::Bytes,
hash: iroh_blobs::Hash,
}
impl decdn_cache::Origin for StubOrigin {
fn kind(&self) -> decdn_cache::OriginKind {
decdn_cache::OriginKind::Http
}
fn fetch(
&self,
hash: iroh_blobs::Hash,
_max_bytes: u64,
) -> std::pin::Pin<
Box<
dyn std::future::Future<
Output = Result<decdn_cache::OriginFetch, decdn_cache::OriginPullError>,
> + Send
+ '_,
>,
> {
let result = if hash == self.hash {
Ok(decdn_cache::OriginFetch::found_one_shot(self.data.clone()))
} else {
Ok(decdn_cache::OriginFetch::NotFound)
};
Box::pin(async move { result })
}
}
#[tokio::test(start_paused = true)]
async fn probe_hold_slots_used_drops_to_zero_after_hold_expires() {
use std::sync::Arc;
use decdn_cache::{CacheEngine, PROBE_HOLD_DURATION, ProbeHoldOutcome};
let payload = b"probe hold gauge".to_vec();
let hash = iroh_blobs::Hash::new(&payload);
let stub = StubOrigin {
data: bytes::Bytes::from(payload),
hash,
};
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open(
tmp.path(),
vec![Arc::new(stub) as Arc<dyn decdn_cache::Origin>],
10,
)
.await
.unwrap();
let _ = engine.get(hash).await.unwrap();
let metrics = Metrics::new();
metrics.attach_probe_holds(engine.clone());
assert_eq!(
engine.try_probe_hold(hash).await.unwrap(),
ProbeHoldOutcome::Held
);
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_probe_hold_slots_used", 1),
"a live hold must read 1:\n{text}"
);
tokio::time::advance(PROBE_HOLD_DURATION + Duration::from_secs(1)).await;
let text = metrics.encode().unwrap();
assert!(
has_metric_line(&text, "decdn_probe_hold_slots_used", 0),
"an expired hold must read 0 with no further probe:\n{text}"
);
}
#[tokio::test]
async fn engine_bumps_surface_in_openmetrics_output() {
use std::sync::Arc;
use bytes::Bytes;
use decdn_cache::{CacheEngine, PinnedHashes, RetryPolicy};
use iroh_blobs::Hash;
let payload = b"hello /metrics integration".to_vec();
let hash = Hash::new(&payload);
let stub = StubOrigin {
data: Bytes::from(payload.clone()),
hash,
};
let metrics = Arc::new(Metrics::new());
let cache_handle = metrics.cache_metrics();
let tmp = tempfile::tempdir().unwrap();
let engine = CacheEngine::open_full(
tmp.path(),
vec![Arc::new(stub) as Arc<dyn decdn_cache::Origin>],
10,
PinnedHashes::empty(),
RetryPolicy::default(),
decdn_cache::CircuitBreakerPolicy::default(),
Some(Arc::clone(&cache_handle)),
std::time::Duration::ZERO,
)
.await
.unwrap();
let _ = engine.get(hash).await.unwrap();
let _ = engine.get(hash).await.unwrap();
let text = metrics.encode().unwrap();
let payload_len = u64::try_from(payload.len()).unwrap_or(u64::MAX);
for (name, expected) in [
("decdn_cache_hits_total", 1u64),
("decdn_cache_misses_total", 1),
("decdn_cache_pull_through_bytes_total", payload_len),
("decdn_cache_bytes_returned_total", payload_len * 2),
] {
assert!(
has_metric_line(&text, name, expected),
"counter {name} should report {expected} after 1 miss + 1 hit:\n{text}"
);
}
}
#[test]
fn serve_cache_hit_family_exports_and_records() {
let metrics = Metrics::new();
let names = [
"decdn_serve_cache_hit_total",
"decdn_serve_cache_partial_hit_total",
"decdn_serve_cache_miss_total",
];
let text = metrics.encode().unwrap();
for name in names {
assert!(
has_metric_line(&text, name, 0),
"{name} must export at zero before the first serve:\n{text}"
);
}
metrics.serve_cache_hit();
metrics.serve_cache_partial_hit();
metrics.serve_cache_miss();
let text = metrics.encode().unwrap();
for name in names {
assert!(
has_metric_line(&text, name, 1),
"{name} should report 1 after its recorder fired once:\n{text}"
);
}
}
#[tokio::test]
async fn bind_accepts_loopback_and_warns_on_non_loopback() {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
let v4_loopback = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0);
let listener = bind(v4_loopback).unwrap();
let bound = listener.local_addr().unwrap();
assert!(
bound.ip().is_loopback(),
"IPv4 loopback bind should resolve to a loopback addr: got {bound}"
);
drop(listener);
let v6_loopback = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0);
if let Ok(listener) = bind(v6_loopback) {
let bound = listener.local_addr().unwrap();
assert!(
bound.ip().is_loopback(),
"IPv6 loopback bind should resolve to a loopback addr: got {bound}"
);
}
let unspecified = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0);
let listener = bind(unspecified).unwrap();
let bound = listener.local_addr().unwrap();
assert!(
bound.ip().is_unspecified(),
"0.0.0.0 bind should resolve to the unspecified addr: got {bound}"
);
drop(listener);
}
#[test]
fn cache_metrics_handle_shares_atomic_with_registered_group() {
let metrics = Metrics::new();
let handle = metrics.cache_metrics();
handle.origin_fetches.inc();
handle.origin_retry_exhausted.inc();
handle.origin_fallback.inc();
handle.hits.inc();
handle.misses.inc();
handle.bytes_returned.inc_by(1024);
handle.pull_through_bytes.inc_by(2048);
handle.gc_runs.inc();
handle.gc_bytes_reclaimed.inc_by(4096);
let text = metrics.encode().unwrap();
for (name, expected) in [
("decdn_cache_origin_fetches_total", 1u64),
("decdn_cache_origin_retry_exhausted_total", 1),
("decdn_cache_hits_total", 1),
("decdn_cache_misses_total", 1),
("decdn_cache_bytes_returned_total", 1024),
("decdn_cache_pull_through_bytes_total", 2048),
("decdn_cache_gc_runs_total", 1),
("decdn_cache_gc_bytes_reclaimed_total", 4096),
] {
assert!(
has_metric_line(&text, name, expected),
"counter {name} should report {expected}:\n{text}"
);
}
}
#[test]
fn pool_open_failures_by_reason_label_distinct_counters() {
let metrics = Metrics::new();
let text = metrics.encode().unwrap();
for name in [
"decdn_pool_open_failures_insufficient_deposit_total",
"decdn_pool_open_failures_contract_revert_total",
"decdn_pool_open_failures_rpc_error_total",
] {
assert!(
has_metric_line(&text, name, 0),
"reason counter {name} should start at zero:\n{text}"
);
}
metrics.pool_open_failure_by_reason(PoolOpenFailureReason::InsufficientDeposit);
metrics.pool_open_failure_by_reason(PoolOpenFailureReason::InsufficientDeposit);
metrics.pool_open_failure_by_reason(PoolOpenFailureReason::ContractRevert);
metrics.pool_open_failure_by_reason(PoolOpenFailureReason::RpcError);
metrics.pool_open_failure_by_reason(PoolOpenFailureReason::RpcError);
metrics.pool_open_failure_by_reason(PoolOpenFailureReason::RpcError);
let text = metrics.encode().unwrap();
for (name, expected) in [
("decdn_pool_open_failures_insufficient_deposit_total", 2u64),
("decdn_pool_open_failures_contract_revert_total", 1),
("decdn_pool_open_failures_rpc_error_total", 3),
] {
assert!(
has_metric_line(&text, name, expected),
"reason counter {name} should report {expected}:\n{text}"
);
}
assert!(
has_metric_line(&text, "decdn_node_pull_pool_open_failures_total", 0),
"unlabeled total must not move when only the by-reason helper is called:\n{text}"
);
}
#[test]
fn iroh_metrics_export_under_the_decdn_iroh_prefix() {
let metrics = Metrics::new();
let before = exported_series(&metrics.encode().unwrap());
assert!(
!before.iter().any(|n| n.starts_with("decdn_iroh_")),
"decdn_iroh_* exported before any endpoint registered"
);
metrics
.register_iroh_metrics(&EndpointMetrics::default())
.unwrap();
let after = exported_series(&metrics.encode().unwrap());
let socket: Vec<&String> = after
.iter()
.filter(|n| n.starts_with("decdn_iroh_socket_"))
.collect();
assert!(
!socket.is_empty(),
"no decdn_iroh_socket_* series after registering EndpointMetrics; \
decdn_iroh_* exported: {:?}",
after
.iter()
.filter(|n| n.starts_with("decdn_iroh_"))
.collect::<Vec<_>>()
);
assert!(
!after
.iter()
.any(|n| n.starts_with("decdn_iroh_decdn_iroh_")),
"iroh metrics carry a doubled prefix"
);
}
#[test]
fn inbound_failure_reasons_hold_no_duplicate() {
let unique: std::collections::BTreeSet<&str> =
INBOUND_FAILURE_REASONS.iter().copied().collect();
assert_eq!(unique.len(), INBOUND_FAILURE_REASONS.len());
}