referrerpolicy=no-referrer-when-downgrade

sc_hop/
metrics.rs

1// Copyright (C) Parity Technologies (UK) Ltd.
2// SPDX-License-Identifier: GPL-3.0-or-later WITH Classpath-exception-2.0
3
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13
14// You should have received a copy of the GNU General Public License
15// along with this program. If not, see <https://www.gnu.org/licenses/>.
16
17//! Prometheus metrics for the HOP pool, RPC, and promotion task.
18
19use crate::types::HopError;
20use prometheus_endpoint::{
21	register, Counter, CounterVec, Gauge, Opts, PrometheusError, Registry, U64,
22};
23
24/// `reason` label values for `substrate_hop_pool_removed_total`.
25pub mod removal_reasons {
26	/// All recipients acknowledged; the entry served its purpose.
27	pub const ACKED: &str = "acked";
28	/// Expired after the node observed the entry on-chain.
29	pub const EXPIRED_PROMOTED: &str = "expired_promoted";
30	/// Expired without the node ever observing the entry on-chain. An upper
31	/// bound on loss: re-checking stops at `MAX_PROMOTION_ATTEMPTS`.
32	pub const EXPIRED_UNPROMOTED: &str = "expired_unpromoted";
33	/// Blob failed its content-hash integrity check.
34	pub const CORRUPT: &str = "corrupt";
35	/// Lost to startup recovery: `.meta` unreadable, undecodable, stale-version,
36	/// or missing its blob.
37	pub const STARTUP_DROPPED: &str = "startup_dropped";
38
39	/// Every reason, for pre-creating the series at registration.
40	pub const ALL: [&str; 5] =
41		[ACKED, EXPIRED_PROMOTED, EXPIRED_UNPROMOTED, CORRUPT, STARTUP_DROPPED];
42}
43
44/// `method` label values: the wire names, so they join against the RPC
45/// middleware's `substrate_rpc_calls_*`.
46pub mod rpc_methods {
47	pub const SUBMIT: &str = "hop_submit";
48	pub const CLAIM: &str = "hop_claim";
49	pub const ACK: &str = "hop_ack";
50}
51
52/// Stable low-cardinality label for a [`HopError`] variant.
53fn error_label(err: &HopError) -> &'static str {
54	match err {
55		HopError::DataTooLarge(_, _) => "data_too_large",
56		HopError::PoolFull(_, _) => "pool_full",
57		HopError::DuplicateEntry => "duplicate_entry",
58		HopError::NotFound => "not_found",
59		HopError::EmptyData => "empty_data",
60		HopError::InvalidSignature => "invalid_signature",
61		HopError::NotRecipient => "not_recipient",
62		HopError::NoRecipients => "no_recipients",
63		HopError::InvalidRecipientKey => "invalid_recipient_key",
64		HopError::UserQuotaExceeded { .. } => "user_quota_exceeded",
65		HopError::NotAuthorized => "not_authorized",
66		HopError::IoError(_) => "io_error",
67		HopError::InvalidSigner => "invalid_signer",
68		HopError::AlreadyClaimed => "already_claimed",
69		HopError::InvalidHashLength(_) => "invalid_hash_length",
70		HopError::RuntimeApiError(_) => "runtime_api_error",
71		HopError::TooManyRecipients { .. } => "too_many_recipients",
72		HopError::DuplicateRecipient => "duplicate_recipient",
73		HopError::RateLimited { .. } => "rate_limited",
74		HopError::MissingDataDir => "missing_data_dir",
75		HopError::Db(_) => "db",
76	}
77}
78
79struct Inner {
80	pool_entries: Gauge<U64>,
81	pool_bytes: Gauge<U64>,
82	pool_max_bytes: Gauge<U64>,
83	pool_inserted_bytes_total: Counter<U64>,
84	pool_removed_total: CounterVec<U64>,
85	rpc_errors_total: CounterVec<U64>,
86	promotions_confirmed_total: Counter<U64>,
87	promotion_backlog: Gauge<U64>,
88	maintenance_ticks_total: Counter<U64>,
89}
90
91impl Inner {
92	fn register(registry: &Registry) -> Result<Self, PrometheusError> {
93		let pool_removed_total = register(
94			CounterVec::new(
95				Opts::new(
96					"substrate_hop_pool_removed_total",
97					"Total number of entries removed from the HOP pool, by reason",
98				),
99				&["reason"],
100			)?,
101			registry,
102		)?;
103		// A CounterVec exports nothing until a series is touched; pre-create
104		// them all so the first data-loss event is a visible 0->1 transition.
105		for reason in removal_reasons::ALL {
106			pool_removed_total.with_label_values(&[reason]);
107		}
108		Ok(Self {
109			pool_entries: register(
110				Gauge::new("substrate_hop_pool_entries", "Number of entries in the HOP pool")?,
111				registry,
112			)?,
113			pool_bytes: register(
114				Gauge::new(
115					"substrate_hop_pool_bytes",
116					"Accounted size of the HOP pool in bytes (data plus per-recipient overhead)",
117				)?,
118				registry,
119			)?,
120			pool_max_bytes: register(
121				Gauge::new(
122					"substrate_hop_pool_max_bytes",
123					"Configured maximum HOP pool size in bytes",
124				)?,
125				registry,
126			)?,
127			pool_inserted_bytes_total: register(
128				Counter::new(
129					"substrate_hop_pool_inserted_bytes_total",
130					"Total accounted bytes successfully inserted into the HOP pool",
131				)?,
132				registry,
133			)?,
134			pool_removed_total,
135			// Per-method call counts and durations are already covered by the
136			// RPC middleware (`substrate_rpc_calls_*`); this only adds the
137			// error-variant granularity the middleware cannot see.
138			rpc_errors_total: register(
139				CounterVec::new(
140					Opts::new(
141						"substrate_hop_rpc_errors_total",
142						"Total number of failed HOP RPC requests, by method and reason",
143					),
144					&["method", "reason"],
145				)?,
146				registry,
147			)?,
148			promotions_confirmed_total: register(
149				Counter::new(
150					"substrate_hop_promotions_confirmed_total",
151					"Total number of HOP entries confirmed as promoted on-chain",
152				)?,
153				registry,
154			)?,
155			promotion_backlog: register(
156				Gauge::new(
157					"substrate_hop_promotion_backlog",
158					"Number of unpromoted HOP entries inside the promotion window",
159				)?,
160				registry,
161			)?,
162			maintenance_ticks_total: register(
163				Counter::new(
164					"substrate_hop_maintenance_ticks_total",
165					"Total number of completed HOP maintenance cycles (promotion + cleanup)",
166				)?,
167				registry,
168			)?,
169		})
170	}
171}
172
173/// HOP metrics; every recorder is a no-op when built without a `Registry`.
174pub struct HopMetrics {
175	inner: Option<Inner>,
176}
177
178impl HopMetrics {
179	/// Register the metrics with the given Prometheus registry, if any.
180	pub fn new(registry: Option<&Registry>) -> Result<Self, PrometheusError> {
181		Ok(Self { inner: registry.map(Inner::register).transpose()? })
182	}
183
184	/// Create no-op metrics.
185	pub fn disabled() -> Self {
186		Self { inner: None }
187	}
188
189	/// Whether metrics were registered.
190	pub(crate) fn is_enabled(&self) -> bool {
191		self.inner.is_some()
192	}
193
194	/// Set the pool gauges to absolute values (used after disk recovery).
195	pub(crate) fn set_pool_status(&self, entries: u64, bytes: u64, max_bytes: u64) {
196		if let Some(inner) = &self.inner {
197			inner.pool_entries.set(entries);
198			inner.pool_bytes.set(bytes);
199			inner.pool_max_bytes.set(max_bytes);
200		}
201	}
202
203	/// Snapshot the pool size gauges from the authoritative counts. Snapshots
204	/// rather than inc/dec because a `Gauge<U64>` wraps on underflow; callers
205	/// publish under the pool's index lock so updates arrive in order.
206	pub(crate) fn set_pool_size(&self, entries: u64, bytes: u64) {
207		if let Some(inner) = &self.inner {
208			inner.pool_entries.set(entries);
209			inner.pool_bytes.set(bytes);
210		}
211	}
212
213	/// Count `accounted` bytes successfully inserted.
214	pub(crate) fn record_inserted_bytes(&self, accounted: u64) {
215		if let Some(inner) = &self.inner {
216			inner.pool_inserted_bytes_total.inc_by(accounted);
217		}
218	}
219
220	/// Record `entries` removals under `reason`.
221	pub(crate) fn record_removed(&self, reason: &str, entries: u64) {
222		if let Some(inner) = &self.inner {
223			inner.pool_removed_total.with_label_values(&[reason]).inc_by(entries);
224		}
225	}
226
227	/// Record one failed RPC request.
228	pub(crate) fn record_rpc_error(&self, method: &str, err: &HopError) {
229		if let Some(inner) = &self.inner {
230			inner.rpc_errors_total.with_label_values(&[method, error_label(err)]).inc();
231		}
232	}
233
234	/// Record one entry confirmed as promoted on-chain.
235	pub(crate) fn record_promotion_confirmed(&self) {
236		if let Some(inner) = &self.inner {
237			inner.promotions_confirmed_total.inc();
238		}
239	}
240
241	/// Set the promotion backlog gauge.
242	pub(crate) fn set_promotion_backlog(&self, backlog: u64) {
243		if let Some(inner) = &self.inner {
244			inner.promotion_backlog.set(backlog);
245		}
246	}
247
248	/// Count one completed maintenance cycle (liveness signal).
249	pub(crate) fn record_maintenance_tick(&self) {
250		if let Some(inner) = &self.inner {
251			inner.maintenance_ticks_total.inc();
252		}
253	}
254
255	/// Counter value of `substrate_hop_pool_removed_total{reason}`.
256	#[cfg(test)]
257	pub(crate) fn removed_count(&self, reason: &str) -> u64 {
258		self.inner
259			.as_ref()
260			.map(|i| i.pool_removed_total.with_label_values(&[reason]).get())
261			.unwrap_or(0)
262	}
263
264	/// Current value of the pool entries / bytes gauges.
265	#[cfg(test)]
266	pub(crate) fn pool_gauges(&self) -> (u64, u64) {
267		self.inner
268			.as_ref()
269			.map(|i| (i.pool_entries.get(), i.pool_bytes.get()))
270			.unwrap_or((0, 0))
271	}
272
273	/// Counter value of `substrate_hop_promotions_confirmed_total`.
274	#[cfg(test)]
275	pub(crate) fn promotions_confirmed(&self) -> u64 {
276		self.inner.as_ref().map(|i| i.promotions_confirmed_total.get()).unwrap_or(0)
277	}
278
279	/// Current value of the `substrate_hop_promotion_backlog` gauge.
280	#[cfg(test)]
281	pub(crate) fn promotion_backlog(&self) -> u64 {
282		self.inner.as_ref().map(|i| i.promotion_backlog.get()).unwrap_or(0)
283	}
284
285	/// Counter value of `substrate_hop_rpc_errors_total{method,reason}`.
286	#[cfg(test)]
287	pub(crate) fn rpc_error_count(&self, method: &str, reason: &str) -> u64 {
288		self.inner
289			.as_ref()
290			.map(|i| i.rpc_errors_total.with_label_values(&[method, reason]).get())
291			.unwrap_or(0)
292	}
293}
294
295#[cfg(test)]
296mod tests {
297	use super::*;
298
299	#[test]
300	fn no_registry_or_disabled_means_no_ops() {
301		assert!(!HopMetrics::new(None).unwrap().is_enabled());
302
303		let metrics = HopMetrics::disabled();
304		metrics.set_pool_status(1, 2, 3);
305		metrics.set_pool_size(1, 2);
306		metrics.record_inserted_bytes(42);
307		metrics.record_removed(removal_reasons::ACKED, 1);
308		metrics.record_rpc_error(rpc_methods::SUBMIT, &HopError::NotFound);
309		metrics.record_promotion_confirmed();
310		metrics.set_promotion_backlog(7);
311		metrics.record_maintenance_tick();
312		assert_eq!(metrics.removed_count(removal_reasons::ACKED), 0);
313	}
314
315	#[test]
316	fn duplicate_registration_fails_and_falls_back_to_disabled() {
317		let registry = Registry::new();
318		assert!(HopMetrics::new(Some(&registry)).unwrap().is_enabled());
319
320		// `HopParams::build_pool` degrades this error into disabled metrics.
321		assert!(matches!(HopMetrics::new(Some(&registry)), Err(PrometheusError::AlreadyReg)));
322
323		let fallback = HopMetrics::new(Some(&registry)).unwrap_or_else(|_| HopMetrics::disabled());
324		assert!(!fallback.is_enabled());
325	}
326
327	#[test]
328	fn removal_series_are_pre_created_at_registration() {
329		let registry = Registry::new();
330		let _metrics = HopMetrics::new(Some(&registry)).unwrap();
331
332		let family = registry
333			.gather()
334			.into_iter()
335			.find(|f| f.get_name() == "substrate_hop_pool_removed_total")
336			.expect("family is registered");
337		assert_eq!(family.get_metric().len(), removal_reasons::ALL.len());
338	}
339
340	#[test]
341	fn enabled_metrics_track_values() {
342		let registry = Registry::new();
343		let metrics = HopMetrics::new(Some(&registry)).unwrap();
344
345		metrics.set_pool_status(2, 200, 1000);
346		metrics.set_pool_size(3, 300);
347		metrics.record_inserted_bytes(100);
348		metrics.record_removed(removal_reasons::EXPIRED_UNPROMOTED, 2);
349		metrics.record_rpc_error(rpc_methods::CLAIM, &HopError::NotFound);
350		metrics.record_promotion_confirmed();
351		metrics.set_promotion_backlog(4);
352		metrics.record_maintenance_tick();
353
354		let inner = metrics.inner.as_ref().unwrap();
355		assert_eq!(metrics.pool_gauges(), (3, 300));
356		assert_eq!(inner.pool_max_bytes.get(), 1000);
357		assert_eq!(inner.pool_inserted_bytes_total.get(), 100);
358		assert_eq!(metrics.removed_count(removal_reasons::EXPIRED_UNPROMOTED), 2);
359		assert_eq!(metrics.removed_count(removal_reasons::ACKED), 0);
360		assert_eq!(metrics.rpc_error_count(rpc_methods::CLAIM, "not_found"), 1);
361		assert_eq!(metrics.promotions_confirmed(), 1);
362		assert_eq!(metrics.promotion_backlog(), 4);
363		assert_eq!(inner.maintenance_ticks_total.get(), 1);
364	}
365}