diff --git a/rs/nns/governance/src/governance/tests/mod.rs b/rs/nns/governance/src/governance/tests/mod.rs index 65a250fdb4a3..06498c3186dc 100644 --- a/rs/nns/governance/src/governance/tests/mod.rs +++ b/rs/nns/governance/src/governance/tests/mod.rs @@ -865,10 +865,15 @@ mod metrics_tests { use crate::{ encode_metrics, governance::Governance, - pb::v1::{Motion, Proposal, ProposalData, Tally, Topic, proposal}, + pb::v1::{ + IcpPriceHistory, MaturityModulation, Motion, Proposal, ProposalData, SampledPrice, + Tally, Topic, proposal, + }, test_utils::{MockEnvironment, StubCMC, StubIcpLedger}, }; + const ONE_DAY_SECONDS: u64 = 86_400; + #[test] fn test_metrics_total_voting_power() { let mut governance = Governance::new( @@ -1020,6 +1025,87 @@ mod metrics_tests { // We assert that decided proposals are filtered out from metrics assert!(!s.contains("proposal_id=\"2\"")); } + + #[test] + fn test_metrics_maturity_modulation_and_icp_price_history() { + const METRIC_MATURITY_MODULATION: &str = + "governance_maturity_modulation_updated_at_timestamp_seconds"; + const METRIC_MISSING_DAYS: &str = "governance_icp_xdr_price_history_missing_days_in_window"; + + let current_day: u64 = 20_000; + let now_seconds = current_day * ONE_DAY_SECONDS; + + // Case 1: both states are None — neither gauge should be emitted. + let governance = Governance::new( + Default::default(), + Arc::new(MockEnvironment::new(Default::default(), now_seconds)), + Arc::new(StubIcpLedger {}), + Arc::new(StubCMC {}), + Box::new(MockRandomness::new()), + ); + let mut writer = ic_metrics_encoder::MetricsEncoder::new(vec![], 0); + encode_metrics(&governance, &mut writer).unwrap(); + let body = writer.into_inner(); + let s = String::from_utf8_lossy(&body); + assert!( + !s.contains(METRIC_MATURITY_MODULATION), + "expected no maturity modulation gauge when state is None, got:\n{s}", + ); + assert!( + !s.contains(METRIC_MISSING_DAYS), + "expected no missing-days gauge when state is None, got:\n{s}", + ); + + // Case 2: both states are Some. + let mut governance = Governance::new( + Default::default(), + Arc::new(MockEnvironment::new(Default::default(), now_seconds)), + Arc::new(StubIcpLedger {}), + Arc::new(StubCMC {}), + Box::new(MockRandomness::new()), + ); + let updated_at_days_since_epoch = current_day - 2; + governance.heap_data.maturity_modulation = Some(MaturityModulation { + current_value_permyriad: Some(42), + updated_at_days_since_epoch: Some(updated_at_days_since_epoch), + }); + // Three of the last 365 days have entries; one stale entry outside the window must not + // count toward `days_present`. + governance.heap_data.icp_price_history = Some(IcpPriceHistory { + icp_xdr_rates: vec![ + SampledPrice { + timestamp_seconds: (current_day - 500) * ONE_DAY_SECONDS, + xdr_permyriad_per_icp: 50_000, + }, + SampledPrice { + timestamp_seconds: (current_day - 2) * ONE_DAY_SECONDS, + xdr_permyriad_per_icp: 50_000, + }, + SampledPrice { + timestamp_seconds: (current_day - 1) * ONE_DAY_SECONDS, + xdr_permyriad_per_icp: 50_000, + }, + SampledPrice { + timestamp_seconds: current_day * ONE_DAY_SECONDS, + xdr_permyriad_per_icp: 50_000, + }, + ], + }); + let mut writer = ic_metrics_encoder::MetricsEncoder::new(vec![], 0); + encode_metrics(&governance, &mut writer).unwrap(); + let body = writer.into_inner(); + let s = String::from_utf8_lossy(&body); + let expected_ts = updated_at_days_since_epoch * ONE_DAY_SECONDS; + assert!( + s.contains(&format!("{METRIC_MATURITY_MODULATION} {expected_ts} 0")), + "expected maturity modulation gauge with value {expected_ts}, got:\n{s}", + ); + let expected_missing_days = 365_u64 - 3; + assert!( + s.contains(&format!("{METRIC_MISSING_DAYS} {expected_missing_days} 0")), + "expected missing-days gauge with value {expected_missing_days}, got:\n{s}", + ); + } } mod neuron_archiving_tests { diff --git a/rs/nns/governance/src/lib.rs b/rs/nns/governance/src/lib.rs index 2aef49af163d..f7e42ae05861 100644 --- a/rs/nns/governance/src/lib.rs +++ b/rs/nns/governance/src/lib.rs @@ -141,7 +141,7 @@ use std::{ time::{Duration, SystemTime}, }; use storage::VOTING_POWER_SNAPSHOTS; -use timer_tasks::encode_timer_task_metrics; +use timer_tasks::{ONE_DAY_SECONDS, encode_timer_task_metrics}; #[cfg(any(test, feature = "canbench-rs"))] pub mod test_utils; @@ -702,6 +702,45 @@ pub fn encode_metrics( "Time since the latest voting power snapshot, in seconds. If no snapshot has been taken yet, this will be infinity.", )?; + // Maturity Modulation / ICP-XDR price history freshness. Skipped entirely when the underlying + // state is unset so that alerts do not fire on freshly-installed canisters before the initial + // backfill completes. + + if let Some(updated_at_days_since_epoch) = governance + .heap_data + .maturity_modulation + .as_ref() + .and_then(|mm| mm.updated_at_days_since_epoch) + { + w.encode_gauge( + "governance_maturity_modulation_updated_at_timestamp_seconds", + (updated_at_days_since_epoch * ONE_DAY_SECONDS) as f64, + "Timestamp (seconds since the Unix epoch) of the day for which the cached maturity modulation was last computed.", + )?; + } + + if let Some(icp_price_history) = governance.heap_data.icp_price_history.as_ref() { + const ICP_XDR_PRICE_HISTORY_WINDOW_DAYS: u64 = 365; + let current_day = governance.env.now() / ONE_DAY_SECONDS; + let oldest_day_in_window = + current_day.saturating_sub(ICP_XDR_PRICE_HISTORY_WINDOW_DAYS - 1); + let days_present_in_window = icp_price_history + .icp_xdr_rates + .iter() + .filter(|p| { + let day = p.timestamp_seconds / ONE_DAY_SECONDS; + day >= oldest_day_in_window && day <= current_day + }) + .count() as u64; + let missing_days_in_window = + ICP_XDR_PRICE_HISTORY_WINDOW_DAYS.saturating_sub(days_present_in_window); + w.encode_gauge( + "governance_icp_xdr_price_history_missing_days_in_window", + missing_days_in_window as f64, + "Number of days in [today-364, today] with no ICP/XDR rate entry in icp_price_history.", + )?; + } + // Periodically Calculated (almost entirely detailed neuron breakdowns/rollups) if let Some(metrics) = &governance.heap_data.metrics { diff --git a/rs/nns/governance/src/timer_tasks/mod.rs b/rs/nns/governance/src/timer_tasks/mod.rs index a14432ddcb37..381e8bf0e748 100644 --- a/rs/nns/governance/src/timer_tasks/mod.rs +++ b/rs/nns/governance/src/timer_tasks/mod.rs @@ -16,6 +16,7 @@ use update_icp_xdr_rate_related_data::UpdateIcpXdrRateRelatedData; pub(crate) use update_icp_xdr_rate_related_data::{ MATURITY_MODULATION_MAX_PERMYRIAD_MISSION_70, MATURITY_MODULATION_MIN_PERMYRIAD_MISSION_70, + ONE_DAY_SECONDS, }; use crate::{canister_state::GOVERNANCE, storage::VOTING_POWER_SNAPSHOTS}; diff --git a/rs/nns/governance/unreleased_changelog.md b/rs/nns/governance/unreleased_changelog.md index 94126a0ff421..cc656c79ab52 100644 --- a/rs/nns/governance/unreleased_changelog.md +++ b/rs/nns/governance/unreleased_changelog.md @@ -9,6 +9,16 @@ on the process that this file is part of, see ## Added +* Added two Prometheus gauges to the `/metrics` endpoint to expose maturity modulation + freshness: + - `governance_maturity_modulation_updated_at_timestamp_seconds` — the day for which the + cached maturity modulation was last computed, multiplied by 86400. + - `governance_icp_xdr_price_history_missing_days_in_window` — the number of days in + `[today-364, today]` with no entry in `icp_price_history.icp_xdr_rates`. + + Both gauges are skipped entirely until the underlying state is populated, so a + freshly-installed canister does not trip alerts before the initial backfill completes. + ## Changed ## Deprecated