Skip to content

Commit 8a5725a

Browse files
authored
feat(node): metrics for attestation freshness (#4236)
1 parent f107dd1 commit 8a5725a

6 files changed

Lines changed: 151 additions & 0 deletions

File tree

crates/node/src/indexer/tx_sender.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ use super::IndexerState;
33
use super::tx_signer::{TransactionSigner, TransactionSigners};
44
use crate::config::RespondConfig;
55
use crate::metrics;
6+
use crate::tee::attestation_freshness_metrics::{
7+
record_attestation_landed, record_stored_attestation_expiry,
8+
};
69
use crate::types::{
710
LogTransaction, SignerContext, SubmittedTransaction, SubmittedTransactionStatus,
811
SubmittedTxMetadata,
@@ -284,6 +287,8 @@ async fn observe_tx_result(
284287
.get_participant_attestation(&indexer_state.mpc_contract_id, &args.tls_public_key)
285288
.await?;
286289

290+
record_stored_attestation_expiry(stored_attestation.as_ref());
291+
287292
let Some(stored_attestation) = stored_attestation else {
288293
tracing::debug!(
289294
"no attestation stored on chain for our key; submission not yet landed"
@@ -306,6 +311,7 @@ async fn observe_tx_result(
306311
);
307312

308313
Ok(if attestation_landed {
314+
record_attestation_landed(&Clock::real());
309315
TransactionStatus::Executed
310316
} else {
311317
TransactionStatus::NotExecuted

crates/node/src/metrics.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,3 +479,31 @@ pub static MPC_TEE_ATTESTATION_ATTEMPTS_TOTAL: LazyLock<prometheus::IntCounterVe
479479

480480
pub const MPC_TEE_ATTESTATION_OUTCOME_SUCCESS: &str = "success";
481481
pub const MPC_TEE_ATTESTATION_OUTCOME_FAILURE: &str = "failure";
482+
483+
pub static MPC_ATTESTATION_EXPIRY_TIMESTAMP_SECONDS: LazyLock<prometheus::IntGauge> =
484+
LazyLock::new(|| {
485+
prometheus::register_int_gauge!(
486+
"mpc_attestation_expiry_timestamp_seconds",
487+
"NEAR block time at which the attestation stored on chain for this node's TLS key \
488+
expires. -1 if the stored attestation carries no expiry; 0 if none is stored. \
489+
Subtract mpc_indexer_latest_block_timestamp_seconds, not wall clock, for the \
490+
remaining time"
491+
)
492+
.unwrap()
493+
});
494+
495+
pub static MPC_ATTESTATION_LAST_LANDED_TIMESTAMP_SECONDS: LazyLock<prometheus::IntGauge> =
496+
LazyLock::new(|| {
497+
prometheus::register_int_gauge!(
498+
"mpc_attestation_last_landed_timestamp_seconds",
499+
"Unix time, by this node's own clock, at which it last confirmed an attestation \
500+
submission landed on chain"
501+
)
502+
.unwrap()
503+
});
504+
505+
/// An alert cannot fire on a series that does not exist yet.
506+
pub fn init_attestation_freshness_metrics() {
507+
LazyLock::force(&MPC_ATTESTATION_EXPIRY_TIMESTAMP_SECONDS);
508+
LazyLock::force(&MPC_ATTESTATION_LAST_LANDED_TIMESTAMP_SECONDS);
509+
}

crates/node/src/tee.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod allowed_image_hashes_watcher;
2+
pub mod attestation_freshness_metrics;
23
pub mod image_expiry_metrics;
34
pub mod remote_attestation;
45

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//! Attestation-freshness gauges.
2+
//!
3+
//! Both are absolute timestamps rather than remaining durations: a node that stops re-attesting
4+
//! also stops updating them, and only the absolute form keeps decaying towards now, so a staleness
5+
//! alert still fires on a frozen gauge. At rest both advance hourly — the expiry is re-read on
6+
//! every submission observation, the landing timestamp only when one is confirmed.
7+
8+
use near_mpc_contract_interface::types::VerifiedAttestation;
9+
use near_time::Clock;
10+
11+
use crate::metrics::{
12+
MPC_ATTESTATION_EXPIRY_TIMESTAMP_SECONDS, MPC_ATTESTATION_LAST_LANDED_TIMESTAMP_SECONDS,
13+
};
14+
15+
const NO_ATTESTATION_STORED: i64 = 0;
16+
const NO_EXPIRY: i64 = -1;
17+
18+
pub(crate) fn record_stored_attestation_expiry(stored: Option<&VerifiedAttestation>) {
19+
MPC_ATTESTATION_EXPIRY_TIMESTAMP_SECONDS.set(expiry_gauge_value(stored));
20+
}
21+
22+
pub(crate) fn record_attestation_landed(clock: &Clock) {
23+
MPC_ATTESTATION_LAST_LANDED_TIMESTAMP_SECONDS.set(clock.now_utc().unix_timestamp());
24+
}
25+
26+
fn expiry_gauge_value(stored: Option<&VerifiedAttestation>) -> i64 {
27+
match stored.map(VerifiedAttestation::expiry_timestamp_seconds) {
28+
None => NO_ATTESTATION_STORED,
29+
Some(None) => NO_EXPIRY,
30+
Some(Some(expiry)) => i64::try_from(expiry).unwrap_or(i64::MAX),
31+
}
32+
}
33+
34+
#[cfg(test)]
35+
#[expect(non_snake_case)]
36+
mod tests {
37+
use super::*;
38+
use near_mpc_contract_interface::types::MockAttestation;
39+
use near_time::{FakeClock, Utc};
40+
use rstest::rstest;
41+
42+
const EXPIRES_AT: u64 = 1_754_000_000;
43+
44+
fn mock_with_expiry(expiry_timestamp_seconds: Option<u64>) -> VerifiedAttestation {
45+
VerifiedAttestation::Mock(MockAttestation::WithConstraints {
46+
mpc_docker_image_hash: None,
47+
launcher_docker_compose_hash: None,
48+
expiry_timestamp_seconds,
49+
expected_measurements: None,
50+
})
51+
}
52+
53+
#[test]
54+
fn record_attestation_landed__should_set_the_gauge_to_the_landing_time() {
55+
// Given
56+
let landed_at = Utc::from_unix_timestamp(EXPIRES_AT as i64).unwrap();
57+
58+
// When
59+
record_attestation_landed(&FakeClock::new(landed_at).clock());
60+
61+
// Then
62+
assert_eq!(
63+
MPC_ATTESTATION_LAST_LANDED_TIMESTAMP_SECONDS.get(),
64+
EXPIRES_AT as i64
65+
);
66+
}
67+
68+
#[rstest]
69+
#[case::nothing_stored(None, NO_ATTESTATION_STORED)]
70+
#[case::stored_with_expiry(Some(mock_with_expiry(Some(EXPIRES_AT))), EXPIRES_AT as i64)]
71+
#[case::stored_without_expiry(Some(mock_with_expiry(None)), NO_EXPIRY)]
72+
#[case::unstamped_mock(Some(VerifiedAttestation::Mock(MockAttestation::Valid)), NO_EXPIRY)]
73+
#[case::expiry_beyond_i64(Some(mock_with_expiry(Some(u64::MAX))), i64::MAX)]
74+
fn expiry_gauge_value__should_report_stored_expiry_or_a_sentinel(
75+
#[case] stored: Option<VerifiedAttestation>,
76+
#[case] expected: i64,
77+
) {
78+
// When
79+
let value = expiry_gauge_value(stored.as_ref());
80+
81+
// Then
82+
assert_eq!(value, expected);
83+
}
84+
}

crates/node/src/web.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ impl IntoResponse for AnyhowErrorWrapper {
4949
pub(crate) async fn metrics() -> String {
5050
// Ensure build info metric is always set before gathering metrics
5151
crate::metrics::init_build_info_metric();
52+
crate::metrics::init_attestation_freshness_metrics();
5253

5354
let metric_families = default_registry().gather();
5455
let mut buffer = vec![];

docs/design/node-operator-metrics.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ serves keyshares over the migration service to the backup service registered for
3030
| [`mpc_last_backup_served_timestamp_seconds`](../../crates/node/src/metrics.rs) | Unix time of the last keyshare set served to the backup service | should be recent. A large gap since the last resharing means backups are not being taken. Confirms the node served the keyshares, not that the backup service persisted them. |
3131
| [`mpc_current_epoch_id`](../../crates/node/src/metrics.rs) | epoch id of the keyset the contract currently holds | the reference point for `mpc_last_backup_served_epoch`. Increments on every resharing; unset until the first keyset exists. During a resharing it stays at the old epoch, which is the one still available to back up. |
3232

33+
Attestation freshness, in [`metrics.rs`](../../crates/node/src/metrics.rs). Set from the
34+
on-chain confirmation of an attestation submission, in
35+
[`attestation_freshness_metrics.rs`](../../crates/node/src/tee/attestation_freshness_metrics.rs):
36+
37+
| Metric | Measures | How to interpret |
38+
| --- | --- | --- |
39+
| [`mpc_attestation_last_landed_timestamp_seconds`](../../crates/node/src/metrics.rs) | Unix time of the last attestation submission this node confirmed on chain | should be under an `ATTESTATION_RESUBMISSION_INTERVAL` (1h) old. A sustained gap ends in this node being dropped from the participant set. |
40+
| [`mpc_attestation_expiry_timestamp_seconds`](../../crates/node/src/metrics.rs) | NEAR block time at which the attestation the contract stores for this node's TLS key expires | subtract `mpc_indexer_latest_block_timestamp_seconds` — the clock the contract expires entries against, not wall clock — for the runway before this node is dropped from the participant set. `0` = nothing stored (evicted, or never landed one), `-1` = stored without an expiry. |
41+
3342
## Recommended alerts
3443

3544
```promql
@@ -55,4 +64,26 @@ time() - mpc_last_backup_served_timestamp_seconds > 86400 for 1h
5564
# been served to the backup service since. Also fires if the node has never served
5665
# a backup, since the gauge starts at 0.
5766
mpc_last_backup_served_epoch < mpc_current_epoch_id for 1h
67+
68+
# Re-attestation stuck (warn): nothing landed in three re-attestation intervals.
69+
# The primary signal — it fires within hours, while the expiry alert below only
70+
# does so days later.
71+
time() - mpc_attestation_last_landed_timestamp_seconds > 3 * 3600 for 15m
72+
73+
# Attestation runway low (page): under 3 days before the contract drops this node
74+
# from the participant set. Backstop for the alert above, and reached only about
75+
# four days after submissions stop landing. The threshold is a plain duration
76+
# rather than a fraction of the expiry window (7 days today): keep it below the
77+
# window, and revisit if the window changes. `> 0` drops the sentinels so they are
78+
# not read as timestamps.
79+
mpc_attestation_expiry_timestamp_seconds > 0
80+
and mpc_attestation_expiry_timestamp_seconds - mpc_indexer_latest_block_timestamp_seconds
81+
< 3 * 86400 for 15m
82+
83+
# No attestation stored (page): the contract holds nothing for our TLS key, so this
84+
# node is out of the attested set. Also covers a node that never landed one.
85+
mpc_attestation_expiry_timestamp_seconds == 0 for 15m
5886
```
87+
88+
A `-1` expiry satisfies neither expiry alert, so a node holding an attestation stored
89+
without one is covered by the staleness alert alone.

0 commit comments

Comments
 (0)