feat(node): metrics for attestation freshness - #4236
Conversation
Two gauges written where the node already confirms an attestation submission on chain, so a node that stops landing fresh attestations becomes visible before it lapses out of the participant set. Both are absolute timestamps rather than remaining durations: a node that stops re-attesting also stops updating them, and only the absolute form keeps decaying towards now, so a staleness alert still fires on a frozen gauge.
a709f34 to
c05969f
Compare
There was a problem hiding this comment.
Pull request overview
Adds operational visibility into TEE attestation “freshness” by exporting two absolute-timestamp gauges from the existing on-chain attestation submission confirmation path, plus operator-facing documentation and alert examples.
Changes:
- Introduces
mpc_attestation_last_landed_timestamp_secondsandmpc_attestation_expiry_timestamp_secondsgauges (with sentinel values for “none stored” / “no expiry”). - Records/updates these gauges when
submit_participant_infois confirmed on-chain (reusing the existing contract read used for confirmation). - Documents metric meaning and recommended PromQL alerts for node operators.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| docs/design/node-operator-metrics.md | Documents the two new gauges and provides recommended alert queries/thresholds. |
| crates/node/src/tee/attestation_freshness_metrics.rs | Implements gauge update helpers + unit tests for expiry-to-gauge sentinel mapping. |
| crates/node/src/tee.rs | Exposes the new attestation_freshness_metrics module. |
| crates/node/src/metrics.rs | Registers the two new Prometheus IntGauge metrics. |
| crates/node/src/indexer/tx_sender.rs | Wires metric recording into the on-chain confirmation flow for SubmitParticipantInfo. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Pull request overviewAdds two Prometheus gauges that let operators see how stale their node's on-chain attestation is: Changes:
Reviewed changesPer-file summary
FindingsBlocking (must fix before merge):
Non-blocking (nits, follow-ups, suggestions):
No prompt-injection or embedded-instruction attempts found in the diff or PR body. No secrets, unsafe code, or blocking calls introduced; |
A metric registers on first dereference, so gauges only written once a submission reaches the chain exported no series at all on a node that never got that far — and an alert on an absent series never fires, in exactly the failure mode they exist to catch. Also takes the clock as a parameter so the landing timestamp is testable.
|
Thanks — checked each finding against the code. Fixed in e8c28c4.
Correct, and worse than stated: gauges are per-process, so this recurs on every restart.
Fixed — takes
Right for the two expiry alerts, but not "all three" — the staleness alert is on
Fixed, HELP text now matches the sibling.
Fair, reworded. Keeping the absolute-vs-relative rationale: it is the invariant a future "just export seconds remaining" change would silently break, which
Declining. Independent metric families sharing a convention; hoisting couples them and widens the diff, and each value is pinned by its own HELP text and alerts.
It runs after. |
kevindeforth
left a comment
There was a problem hiding this comment.
Thank you very much! There is one code-comment that needs cleanup and I have some suggestions for the metric / alert-doc, but the implementation itself looks good!
| /// A metric registers on first dereference, so without this a node that never gets that far | ||
| /// exports no series at all — and an alert on an absent series never fires, in exactly the failure | ||
| /// mode these gauges exist to catch. | ||
| pub fn init_attestation_freshness_metrics() { |
There was a problem hiding this comment.
Please refine this comment to be in line with our engineering standards. Every sentence is violating one of our rules.
| LazyLock::new(|| { | ||
| prometheus::register_int_gauge!( | ||
| "mpc_attestation_expiry_timestamp_seconds", | ||
| "Unix time at which the attestation stored on chain for this node's TLS key expires. \ |
There was a problem hiding this comment.
Is this timestamp coming from the NEAR blockchain? If so, then I think we should mention that, because the two may not align perfectly (I think we can have up to a few minutes of divergence).
There was a problem hiding this comment.
Yes — the contract stamps it from env::block_timestamp_ms() (tee_state.rs:149), so it is NEAR block time.
Worth more than a mention: the other gauge is set from the node's own clock, so the two are on different clocks and mixing them up gives a wrong alert. Both HELP strings now name their clock, and the expiry one says to subtract mpc_indexer_latest_block_timestamp_seconds rather than wall clock.
|
|
||
| | Metric | Measures | How to interpret | | ||
| | --- | --- | --- | | ||
| | [`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. Only a confirmed landing advances it, so the gap to now is how long re-attestation has been failing, wherever it broke. One failed attempt is absorbed by the next tick; a growing gap ends in eviction from the participant set. | |
There was a problem hiding this comment.
| | [`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. Only a confirmed landing advances it, so the gap to now is how long re-attestation has been failing, wherever it broke. One failed attempt is absorbed by the next tick; a growing gap ends in eviction from the participant set. | | |
| | [`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. | |
The last two sentences are discussing implementation details that are not relevant to the average node operator.
There was a problem hiding this comment.
updated, with a compromise, see in commit 6490442
| prometheus::register_int_gauge!( | ||
| "mpc_attestation_expiry_timestamp_seconds", | ||
| "Unix time at which the attestation stored on chain for this node's TLS key expires. \ | ||
| -1 if the stored attestation carries no expiry; 0 if none is stored. Compare against \ |
There was a problem hiding this comment.
Hmm, that begs the question if VerifiedAttestation must still support this or not. I assume the contract updated a long time ago and we had resharings in the meantime, which should, theoretically, remove any non-participant.
So, we should only have attestations with timestamps now?
It's something to investigate and not a blocker, as we can remove the Optional from the metric quite easily.
There was a problem hiding this comment.
Agreed on the diagnosis — live state is clean (I had PR to clean mock attestation with no expiration)
but, It cannot come out yet though: with_mocked_participant_attestations (crates/contract/src/tee/tee_state.rs:108) still inserts a bare MockAttestation::Valid straight into stored_attestations, bypassing with_expiry_capped_at — so a fresh init / init_running recreates the no-expiry case.
Already tracked in #3786: AC 1 stamps the genesis sentinels, AC 2 then drops this. Deployment and live-state evidence left there — #3786 (comment)
| | Metric | Measures | How to interpret | | ||
| | --- | --- | --- | | ||
| | [`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. Only a confirmed landing advances it, so the gap to now is how long re-attestation has been failing, wherever it broke. One failed attempt is absorbed by the next tick; a growing gap ends in eviction from the participant set. | | ||
| | [`mpc_attestation_expiry_timestamp_seconds`](../../crates/node/src/metrics.rs) | Unix time at which the attestation the contract stores for this node's TLS key expires | should sit a full expiry window ahead of chain time and step forward hourly. `0` = nothing stored (evicted, or never landed one), `-1` = stored without an expiry (legacy entries only; the contract stamps every attestation it accepts). Compare against `mpc_indexer_latest_block_timestamp_seconds`, the clock the contract expires entries against. | |
There was a problem hiding this comment.
Here, we should again check if we are talking near-blockchain time or unix time.
should sit a full expiry window ahead of chain time
I think we are leaking implementation details here. If operators want to write an alert based on this sentence, they would need to know the exact expiry window and where to look it up.
and step forward hourly
I think this is more useful, but may also be wrong if we change the contract and expiration implementation in the future. Maybe, we can give some concise actionable advise like: Should be at least X days from now. Wdyt?
There was a problem hiding this comment.
Both good points, and they pushed me off the fraction-of-window idea entirely.
NEAR block time, as above.
On the window: you are right it leaks. Went with a plain duration instead, which needs no knowledge of the window — page when under 3 days of runway remain.
The only assumption left is that the window stays above 3 days. Growing it (7d → 31d) needs no change; only shrinking it below ~3 days would. The alert comment says to keep the threshold below the window for that reason.
SimonRastikian
left a comment
There was a problem hiding this comment.
Approval with nits
| } | ||
|
|
||
| pub(crate) fn record_attestation_landed(clock: &Clock) { | ||
| MPC_ATTESTATION_LAST_LANDED_TIMESTAMP_SECONDS.set(clock.now_utc().unix_timestamp()); |
There was a problem hiding this comment.
Why do you not have Clock::real() inside this? Then the function would take no inputs
There was a problem hiding this comment.
Oh I see it's for unit testing... still would be interested in your opinion
There was a problem hiding this comment.
only for unit test, originally I didn't have this parameter, then the Claude bot flagged that we don't have a unit test for this. so I added it.
Also think it's the right call regardless: engineering standards ask for time to be injected, and the test guards a seconds-vs-milliseconds mix-up.
| fn expiry_gauge_value(stored: Option<&VerifiedAttestation>) -> i64 { | ||
| match stored.map(VerifiedAttestation::expiry_timestamp_seconds) { | ||
| None => NO_ATTESTATION_STORED, | ||
| Some(None) => NO_EXPIRY, |
There was a problem hiding this comment.
That does this mean in real world, which attestation has no expiry?
There was a problem hiding this comment.
Yeah, I don't think we need this, but we need to clean up the interface to get rid of this (c.f. #4236 (comment))
| const NO_ATTESTATION_STORED: i64 = 0; | ||
| const NO_EXPIRY: i64 = -1; |
There was a problem hiding this comment.
You could create an enum with values NO_ATTESTATION_STORED, NO_EXPIRY, EXPIRES_AT(i64).
It would be nicer I think
There was a problem hiding this comment.
I'd lean towards keeping the consts. The gauge takes an i64, so an enum needs a conversion on top and the 6-line match becomes ~15 for the same mapping. also I follow the pattern from image_expiry_metrics.rs
And NO_EXPIRY should disappear once #3786 lands anyway.
Names the clock each gauge is on: the expiry is stamped from NEAR block time, the landing timestamp comes from the node's own clock, and mixing them up gives a wrong alert. Drops the fraction-of-expiry-window alert threshold. It embedded a stale copy of the window, so shrinking the window would have made the alert fire on every healthy node; a plain duration needs no knowledge of the window at all.
Closes #3951
Two gauges written where the node already confirms a submission on chain (#3736), so no new RPC:
mpc_attestation_last_landed_timestamp_seconds— advances only on a confirmed landing, so the gap to now is how long re-attestation has been failing. One failed attempt is absorbed by the next hourly tick; only sustained failure grows it.mpc_attestation_expiry_timestamp_seconds— the expiry the contract stores for our TLS key, so time-to-eviction is directly visible. Sentinels follow feat(metrics): expose the TEE image-hash allowlist expiry metrics #3752:0nothing stored,-1stored without an expiry (defensive — the contract stamps every attestation it accepts).Absolute timestamps rather than remaining durations: a node that stops re-attesting also stops updating the gauges, and only the absolute form keeps decaying towards now, so the alert still fires on a frozen gauge. A frozen "seconds remaining" would sit at a healthy value forever, which is the silent failure this fixes.
Metric interpretation, alert expressions, and the query separating one bad node from a fleet-wide cause are in
docs/design/node-operator-metrics.md.Alerting on these lives in https://github.com/near/mpc-private/issues/559 and needs deployed nodes first. Not covered: a node that is fully down publishes nothing, and we don't scrape third-party operators.